- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보) - Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음 - 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미 - 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
301 lines
13 KiB
Python
301 lines
13 KiB
Python
"""M02 일위대가 맞춤 — 조합 풀이(PLAN 338-1 · 설계 2-7): 블록 몫(절마다 노임 · 장비 벡터)을 우리
|
||
로직 단위당 벡터 조합으로 풂 · 섞인 줄은 인력 몫 로직이 1순위(몫 먼저).
|
||
|
||
맞음 = 차원(직종 · 장비 갈래)마다 작은/큰 가중 평균(보통인부 0.5 · 장비 있음 0.5 + 시간 0.5 ·
|
||
크기 다르면 × 0.8 · 갈래 일부만 같으면 × 0.8) · 몫 x(0.1 … 0.9) × 0.95 · 제목 몫 수와 같은 x 면
|
||
+0.1.
|
||
덧 로직 = 1순위가 못 덮는(< COVER) 절은 절 1위(≥ 0.75) · 아니면 몫마다(≥ 0.6) · 장비만 든 몫 =
|
||
「기계 시간당 기계경비」(체크 안 함 · 시간 = 엑셀 값).
|
||
순위 덤 = 앞 차례 0.3 · 인용 조항 같음 0.3 · 같은 책 0.1 · 후보 풀 = 앞 차례 15 + 드문 직종 · 장비
|
||
색인
|
||
(40 개 이하) + 절 제목 이름 후보 8. 우리 벡터 = `M02_Hopyo_Ours` 지문 변형 L · X(따로 안 셈).
|
||
규칙 · 문턱은 336 실험(C5 `c03_vec` · `c05_solve` · C7 `solve`) 그대로 · 문턱은 `M02_Hopyo_Fit`
|
||
머리.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||
|
||
COMMON = "보통인부"
|
||
MACHINE = {"굴착기": "GC000267"} # 굴착기(무한궤도) 시간당 기계경비 · 그 밖 장비 = GC000268
|
||
MACHINE_ANY = "GC000268"
|
||
MACHINE_SET = {"GC000267", "GC000268", "GC000269", "GC000275"}
|
||
_SHARE = re.compile(r"(\d{1,3})\s*%")
|
||
_RATIO = re.compile(r"(\d)\s*:\s*(\d)")
|
||
_STATE: dict[str, Any] = {"판": None}
|
||
|
||
|
||
# ── 우리 벡터 · 색인 ───────────────────────────────────────────────────
|
||
def _vec(v: dict[str, Any]) -> dict[str, Any]:
|
||
eq = [(frozenset(a), frozenset(b), q) for a, b, q in v.get("X") or [] if a and q]
|
||
return {"노임": {t: q for t, q in (v.get("L") or {}).items() if q}, "장비": eq}
|
||
|
||
|
||
def _load() -> dict[str, Any]:
|
||
"""{변형: {로직: [(입력, 벡터)]}, 색인: {차원: {로직}}} — 우리 지문 판마다 한 번."""
|
||
table = ours.table() or []
|
||
ver = ours.version()
|
||
if _STATE["판"] != (ver, id(table)):
|
||
var: dict[str, list] = {}
|
||
idx: dict[str, set] = {}
|
||
for o in table:
|
||
vs = [(x.get("in") or {}, _vec(x)) for x in o.get("변형") or []]
|
||
var[o["로직"]] = [(g, v) for g, v in vs if v["노임"] or v["장비"]]
|
||
if var[o["로직"]]:
|
||
for d in dims(var[o["로직"]][0][1]):
|
||
idx.setdefault(d, set()).add(o["로직"])
|
||
_STATE.update(판=(ver, id(table)), 변형=var, 색인=idx)
|
||
return _STATE
|
||
|
||
|
||
def variants(code: str) -> list[tuple[dict, dict]]:
|
||
return _load()["변형"].get(code) or []
|
||
|
||
|
||
def _kinds(key: str) -> tuple[frozenset, str]:
|
||
kind, _, size = key.partition("|")
|
||
return frozenset(kind.split("+")), size
|
||
|
||
|
||
def dims(E: dict[str, Any]) -> set[str]:
|
||
out = {t for t in E.get("노임") or {} if t != COMMON}
|
||
eq = E.get("장비") or {}
|
||
if isinstance(eq, dict):
|
||
return out | {"+".join(sorted(_kinds(k)[0])) for k in eq}
|
||
return out | {"+".join(sorted(a)) for a, _, _ in eq}
|
||
|
||
|
||
def sim(E: dict[str, Any], ov: dict[str, Any], x: float = 1.0) -> float:
|
||
"""E = 엑셀 몫 {노임, 장비: {「굴착기|0.6」: 시간}} · ov = 우리 벡터 — 0..1."""
|
||
num = den = 0.0
|
||
et, ot = E.get("노임") or {}, ov.get("노임") or {}
|
||
for t in set(et) | set(ot):
|
||
w = 0.5 if t == COMMON else 1.0
|
||
a, b = et.get(t, 0.0), ot.get(t, 0.0) * x
|
||
den += w
|
||
num += w * min(a, b) / max(a, b) if a > 0 and b > 0 else 0
|
||
oe = ov.get("장비") or []
|
||
used = set()
|
||
for k, a in (E.get("장비") or {}).items():
|
||
kinds, size = _kinds(k)
|
||
best, pick = 0.0, None
|
||
for at, (kinds2, sizes2, b) in enumerate(oe):
|
||
if not kinds & kinds2:
|
||
continue
|
||
f = 1.0 if (not size or not sizes2 or float(size) in sizes2) else 0.8
|
||
f *= 1.0 if kinds == kinds2 else 0.8 # 굴착기 ↔ 굴착기+집게
|
||
b *= x
|
||
s = f * (0.5 + 0.5 * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0))
|
||
if s > best:
|
||
best, pick = s, at
|
||
den += 1.0
|
||
num += best
|
||
if pick is not None:
|
||
used.add(pick)
|
||
den += sum(1.0 for at in range(len(oe)) if at not in used)
|
||
return num / den if den else 0.0
|
||
|
||
|
||
def fit_one(E: dict[str, Any], code: str, share: bool) -> tuple[float, dict, float]:
|
||
"""(맞음, 입력, 몫 x) — 변형마다 x = 1 과 몫 x 중 가장 좋은 것."""
|
||
top = (0.0, {}, 1.0)
|
||
for given, ov in variants(code):
|
||
s = sim(E, ov)
|
||
if s > top[0]:
|
||
top = (s, given, 1.0)
|
||
if share:
|
||
for x in fit.SHARES:
|
||
s2 = 0.95 * sim(E, ov, x)
|
||
if s2 > top[0]:
|
||
top = (s2, given, x)
|
||
return top
|
||
|
||
|
||
def cover(E: dict[str, Any], code: str) -> float:
|
||
"""E 의 차원을 로직 벡터가 얼마나 덮나(로직의 남는 차원은 안 셈)."""
|
||
top = 0.0
|
||
for _, ov in variants(code):
|
||
num = den = 0.0
|
||
for t, a in (E.get("노임") or {}).items():
|
||
w = 0.5 if t == COMMON else 1.0
|
||
b = (ov.get("노임") or {}).get(t, 0.0)
|
||
den += w
|
||
num += w * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0)
|
||
for k in E.get("장비") or {}:
|
||
den += 1
|
||
num += any(_kinds(k)[0] & a for a, _, _ in ov.get("장비") or [])
|
||
top = max(top, num / den if den else 0)
|
||
return top
|
||
|
||
|
||
# ── 후보 풀 · 앞선 믿음 ────────────────────────────────────────────────
|
||
def title_cands(title: str, unit: str, got: dict, n: int = 8) -> list[str]:
|
||
text = re.sub(r"\s+", "", re.sub(r"[\d.·:()\[\]%]+", " ", str(title or "")))
|
||
if len(text) < 2:
|
||
return []
|
||
found = hopyo_match.candidates({"이름": text, "단위": unit}, "로직", got, n)
|
||
return [c["로직"] for c in found if c.get("로직")]
|
||
|
||
|
||
class Prior:
|
||
"""앞선 믿음 — 앞 차례 · 줄 근거 조항(윗마디 포함) · 같은 책."""
|
||
|
||
def __init__(self, line: dict[str, Any], ranked: list[str]):
|
||
self.rank = {c: k for k, c in enumerate(ranked)}
|
||
self.n = max(len(ranked), 1)
|
||
self.theirs = [list(x) for x in ((line.get("근거") or {}).get("출처") or [])]
|
||
self.books = {b for b, _ in self.theirs}
|
||
self.table = hopyo_basis.ours()
|
||
|
||
def __call__(self, code: str) -> float:
|
||
p = 0.3 * (1 - self.rank[code] / self.n) if code in self.rank else 0.0
|
||
our = self.table.get(code)
|
||
if our and self.theirs:
|
||
p += 0.3 if hopyo_match.clause(self.theirs, our)[0] >= 0.9 else 0
|
||
p += 0.1 if our["책"] in self.books else 0
|
||
return p
|
||
|
||
|
||
def _sum(parts: list[dict]) -> dict[str, Any]:
|
||
out: dict[str, dict] = {"노임": {}, "장비": {}}
|
||
for s in parts:
|
||
for k in ("노임", "장비"):
|
||
for d, q in (s.get(k) or {}).items():
|
||
out[k][d] = out[k].get(d, 0) + q
|
||
return out
|
||
|
||
|
||
def pool_for(E: dict[str, Any], base: list[str], extra: list[str]) -> list[str]:
|
||
idx = _load()["색인"]
|
||
rare: set[str] = set()
|
||
for d in dims(E):
|
||
if len(idx.get(d, ())) <= 40:
|
||
rare |= idx.get(d, set())
|
||
return list(dict.fromkeys([*base, *extra, *sorted(rare)]))
|
||
|
||
|
||
def _hint(title: Any) -> float | None:
|
||
m = _SHARE.search(str(title or ""))
|
||
return int(m.group(1)) / 100 if m and 0 < int(m.group(1)) < 100 else None
|
||
|
||
|
||
def ranked(E: dict, pool: list[str], prior: Prior, share: bool, hint: float | None) -> list[tuple]:
|
||
"""[(순위 점수, 맞음, 몫 x, 로직, 입력)] — 순위 차례 · 제목 몫과 같은 x 면 +0.1."""
|
||
out = []
|
||
for c in pool:
|
||
f, given, x = fit_one(E, c, share)
|
||
if f <= 0:
|
||
continue
|
||
score = f + prior(c)
|
||
if hint is not None:
|
||
score += 0.1 if abs(x - hint) < 1e-9 else -0.1
|
||
out.append((score, f, x, c, given))
|
||
out.sort(key=lambda z: -z[0])
|
||
return out
|
||
|
||
|
||
def is_mixed(parts: list[dict], line: dict[str, Any]) -> bool:
|
||
"""섞인 줄 — 제목 · 이름 · 규격에 몫 표시 + 노임만 든 몫 + 장비만 든 몫."""
|
||
text = " ".join(
|
||
[str(p.get("제목")) for p in parts] + [str(line.get(k)) for k in ("이름", "규격")]
|
||
)
|
||
marked = bool(_SHARE.search(text) or _RATIO.search(text))
|
||
man = any(p.get("노임") and not p.get("장비") for p in parts)
|
||
mach = any(p.get("장비") and not p.get("노임") for p in parts)
|
||
return marked and man and mach
|
||
|
||
|
||
def machine_of(E: dict[str, Any]) -> str:
|
||
kinds = {frozenset(_kinds(k)[0]) for k in E.get("장비") or {}}
|
||
return MACHINE["굴착기"] if kinds == {frozenset({"굴착기"})} else MACHINE_ANY
|
||
|
||
|
||
# ── 풀이 ───────────────────────────────────────────────────────────────
|
||
def combine(
|
||
primary: str | None, parts: list[dict], line: dict, got: dict, base: list[str]
|
||
) -> list[dict[str, Any]]:
|
||
"""[{로직, 어디, 맞음, 몫, 단계, 입력}] — 1순위 + 1순위가 못 덮는 절 · 몫의 로직."""
|
||
one = lambda c, w, f, x, step, g=None: { # noqa: E731
|
||
"로직": c,
|
||
"어디": w,
|
||
"맞음": f,
|
||
"몫": x,
|
||
"단계": step,
|
||
"입력": g or {},
|
||
}
|
||
chosen = [one(primary, "1순위", None, 1.0, "1순위")] if primary else []
|
||
if not parts:
|
||
return chosen
|
||
prior, unit = Prior(line, base), str(line.get("단위") or "")
|
||
mixed = is_mixed(parts, line)
|
||
groups: dict[str, list] = {}
|
||
for p in parts:
|
||
groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p)
|
||
have = {primary} if primary else set()
|
||
|
||
def machine(E: dict, where: str) -> None:
|
||
if mixed or len(groups) > 1:
|
||
m = machine_of(E)
|
||
chosen.append(one(m, where, None, 1.0, "장비 몫"))
|
||
have.add(m)
|
||
|
||
for title, ps in groups.items():
|
||
E = _sum(ps)
|
||
if primary and cover(E, primary) >= fit.COVER:
|
||
continue
|
||
if E["장비"] and not E["노임"]: # 장비만 든 절 = 기계 시간 로직(닫힌 세계 규칙)
|
||
machine(E, title)
|
||
continue
|
||
share = any(_hint(p.get("제목")) for p in ps) or _hint(title) is not None
|
||
pool = pool_for(E, base, title_cands(title, unit, got))
|
||
cand = ranked(E, pool, prior, share, _hint(title))
|
||
if cand and cand[0][1] >= fit.COMBO[1] and cand[0][3] not in have:
|
||
chosen.append(
|
||
one(cand[0][3], title, round(cand[0][1], 3), cand[0][2], "절", cand[0][4])
|
||
)
|
||
have.add(cand[0][3])
|
||
continue
|
||
for p in ps:
|
||
if primary and cover(p, primary) >= fit.COVER:
|
||
continue
|
||
if p.get("장비") and not p.get("노임"):
|
||
machine(p, p.get("제목"))
|
||
continue
|
||
hint = _hint(p.get("제목"))
|
||
pool = pool_for(p, base, title_cands(p.get("제목"), unit, got))
|
||
cand = ranked(p, pool, prior, hint is not None or share, hint)
|
||
if cand and cand[0][1] >= fit.COMBO[2] and cand[0][3] not in have:
|
||
f, x, c, g = round(cand[0][1], 3), cand[0][2], cand[0][3], cand[0][4]
|
||
chosen.append(one(c, p.get("제목"), f, x, "몫", g))
|
||
have.add(c)
|
||
return chosen
|
||
|
||
|
||
def solve(
|
||
top: list[str], parts: list[dict], line: dict, got: dict, base: list[str]
|
||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||
"""(새 차례, 조합 대상) — 섞인 줄이고 1순위가 블록 합을 MIXED_CUT 못 덮으면 1순위를 인력 몫
|
||
로직으로 바꿈(몫 먼저) · 조합이 하나뿐이면 대상 빈 것."""
|
||
chosen = combine((top or [None])[0], parts, line, got, base)
|
||
codes = list(dict.fromkeys(c["로직"] for c in chosen if c["로직"]))
|
||
if top and len(codes) > 1 and parts and is_mixed(parts, line):
|
||
if cover(_sum(parts), top[0]) < fit.MIXED_CUT:
|
||
labor = [c for c in chosen if c["로직"] != top[0] and c["단계"] in ("몫", "절")]
|
||
if labor:
|
||
chosen = [c for c in chosen if c["로직"] != top[0]]
|
||
top = [labor[0]["로직"]] + [c for c in top if c != labor[0]["로직"]][:4]
|
||
seen: set[str] = set()
|
||
chosen = [
|
||
c for c in chosen if not (c["로직"] in seen or seen.add(c["로직"]))
|
||
] # 같은 로직은 처음 것
|
||
if len(chosen) < 2:
|
||
return top, []
|
||
return top, [{**c, "체크": c["로직"] not in MACHINE_SET or c["로직"] == top[0]} for c in chosen]
|