"""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]