Files
Aislo/M02_MasterTemplete/M02_Hopyo_Combine.py
T
eomsangdonandClaude Opus 5.5 57af9be22f fix(784-1): 「우리 품」 잘못 보임 뿌리 고침 — 못 맞춤 빈칸 · 가드 칸 자동값 0 · 수 칸 짝 · 자재 단위 배 1 · 절 하나 덧 없음 · 엑셀 값 표지
- A 옵션 찾기 못 맞춤(수준 9 · 거리 ≥ 1) = 우리 품 · 입력 안 냄 · 「못맞춤」 표지 · 꼬리표 「→ –(우리 셈 조건 못 찾음)」
- B 자동값 「만약(X > 0, X, 기본)」 칸 = 나눗수 퍼뜨림에서 뺌(0 = 원문 기본) · 옵션 후보엔 옛 1 도 둠
- C 하나씩 조합 — 후보 ≤ 4 수 칸도 둘씩 짝
- D 엑셀이 값으로 넣은 「0 = 식」 칸 출처 「엑셀 값 · 식 아님」 · 꼬리표 「원문 식이면 품 · 금액(조건)」
- E 결과단위 「자재」 = 배 1 · 공/㎥ ≡ 공㎥
- F 품 비교 우리 품 × 대상 몫
- G 절 하나 블록 = 덧 로직은 후보만(이중 계상 막음)
- H 1위 옵션 수준 ≤ 2 면 그 입력을 대상 입력값으로
- I 규격 「L=…cm」 를 거리 칸에 안 넣음 · 수 고르기는 제 단위 붙은 수만 · 엑셀 수 「1 ㎡」 를 m 로 안 읽음
- 우리지문 · 맞춤 캐시 판 올림(FP 2 · CALC 3)
- 새 지킴 시험 test_635_hopyo_ours_qty
- 낡은 시험 둘(test_628 표지 수 18 · test_621 S1 울진소광 해시)은 HEAD 에서도 실패 — 782-3 마스터 합침 뒤 값 · 손대지 않음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:41:09 +09:00

330 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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) · 장비만 든 몫 =
「기계 시간당 기계경비」(체크 안 함 · 시간 = 엑셀 값).
덧은 근거가 닿을 때만(`reached` — 단위 맞음 + 조항 · 직종(보통인부 뺌) · 이름 하나) · 아니면 덧
안 붙임(후보로만) · 섞인 줄(몫 먼저)은 그대로.
순위 덤 = 앞 차례 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["로직"])
trades = {o["로직"]: set(o.get("직종") or ()) for o in table}
_STATE.update(판=(ver, id(table)), 변형=var, 색인=idx, 직종=trades)
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 reached(code: str, line: dict, where: Any, E: dict, prior: Prior, got: dict) -> bool:
"""덧 근거 닿음 — 단위 맞음 + (인용 조항 같음 · 직종 겹침(보통인부 뺌) · 이름 낱말 겹침)."""
one = next((x for x in got["로직"] if x["로직"] == code), None)
if one is None or not fit.unit_fit(line.get("단위"), one["단위"]):
return False
our = prior.table.get(code)
theirs = prior.theirs + hopyo_basis.sources(str(where or ""))
if our and theirs and hopyo_match.clause(theirs, our)[0] >= 0.9:
return True
if (set(E.get("노임") or {}) - {COMMON}) & _load()["직종"].get(code, set()):
return True
loose = lambda t: hopyo_basis._loose_words(t) - hopyo_match._GENERIC # noqa: E731
mine, theirs_w = loose(f"{where or ''} {line.get('이름') or ''}"), loose(one["이름"])
return any(a in b or b in a for a in mine for b in theirs_w)
# ── 풀이 ───────────────────────────────────────────────────────────────
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)
single = bool(primary) and len(groups) == 1 # 절 하나 = 덧은 후보만(같은 몫 이중 계상 막음)
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:
ok = mixed or reached(cand[0][3], line, title, E, prior, got)
f, x, c, g = round(cand[0][1], 3), cand[0][2], cand[0][3], cand[0][4]
chosen.append(one(c, title, f, x, "절" if ok else "후보", g))
have.add(cand[0][3])
if ok:
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]
ok = mixed or reached(c, line, p.get("제목"), p, prior, got)
chosen.append(one(c, p.get("제목"), f, x, "몫" if ok else "후보", g))
have.add(c)
if single:
return [c if c["단계"] in ("1순위", "후보") else {**c, "단계": "후보"} for c in chosen
if c["단계"] != "장비 몫"] # fmt: skip
return chosen
def solve(
top: list[str], parts: list[dict], line: dict, got: dict, base: list[str]
) -> tuple[list[str], list[dict[str, Any]], list[str]]:
"""(새 차례, 조합 대상, 덧 후보) — 섞인 줄이고 1순위가 블록 합을 MIXED_CUT 못 덮으면 1순위를
인력 몫 로직으로 바꿈(몫 먼저) · 조합이 하나뿐이면 대상 빈 것 · 덧 후보 = 근거 안 닿은 덧."""
chosen = combine((top or [None])[0], parts, line, got, base)
spare = [c["로직"] for c in chosen if c["단계"] == "후보"]
chosen = [c for c in chosen if c["단계"] != "후보"]
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, [], spare
combo = [{**c, "체크": c["로직"] not in MACHINE_SET or c["로직"] == top[0]} for c in chosen]
return top, combo, spare