Files
Aislo/M02_MasterTemplete/M02_San_Basis_Index.py
eomsangdonandClaude Opus 5.5 532cc52c8e feat(793-3a): 근거 제안 엔진 — 번호 ∩ 값 ∩ 제목 ∩ 기계 상수 → M01 기초 + 옵션 · 품 검산 · 세 단
- M01 값 역색인 · 숫자 정규화 한 벌(N1~N10 · 단위 기준 · % ↔ 소수 · t ↔ kg · 인/양 몫) · M01 지문마다 한 번 · 서버 시작 때 뒤에서 지음
- 값 길(상수 지문 → 로직 · 우연 보정 유의 4) · 합친 길(분석 648-D2 차례 · 후보 2~6 엔진 품 대조) · 검산 · 옵션(B1~B6 → 값 길 V → 고르기 맞댐 → 입력 필요)
- 세 단 확정 · 근접(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이) · 사람(+ 값 약한 1 등) · 없음(M01 보강 목록)
- 길 GET hopyo-basis/{키} · hopyo-basis/{키}/{번} · 파생 저장본 · 올림 때 HP 숨은식(STmate G 식) · 끝내기 때 지움 · 판정 길 그대로
- 보정 자료 = 여덟 파일 상수 1,094 · 시험 test_793_3a 11(459 블록 기초 260 · 옵션 전부 52 · 품 같음 141 · 없음 85)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:19:26 +09:00

612 lines
24 KiB
Python
Raw Permalink 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.
"""근거 제안 1 — M01 값 역색인 · 숫자 정규화 한 벌(PLAN 793-3a · 요구 648 · 분석 648-D 1 장) ·
읽기만.
칸(`cells`) = 표 칸 하나 {v, 표, 줄, 칸, 단위, 종류 val(값칸) | cond(조건 수) | ratio(「a 인 / b」
몫) |
기준(「10㎡」 당) | eq(기계 요소 값)} · 쓰임(`uses`) = 표 → [(로직, 부름, 칸 | None, 글자 거름,
입력 묶음)]
(하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` ·
직종 표지).
정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 ·
N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 =
`M02_San_Basis_Const`).
우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 ·
포아송 이항 꼬리 −log10.
짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시).
"""
from __future__ import annotations
import ast
import bisect
import glob
import json
import math
import re
import threading
import unicodedata
from collections import defaultdict
from functools import lru_cache
from pathlib import Path
from typing import Any
from common_util import common_util_derived_store as derived
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
mf, ma = hb.mf, hb.ma
CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json")
INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈
PERSON = ("인",)
# ── 단위 성격(N8) ─────────────────────────────────────────────────────
FAM = [
x.split(":")
for x in (
"인:사람 ㎡:넓이 m2:넓이 ㎥:부피 m3:부피 ℓ:부피 km:길이 ㎝:길이 cm:길이 ㎜:길이 mm:길이 "
"m:길이 ㎏:무게 kg:무게 ton:무게 톤:무게 t:무게 g:무게 %:율 hr:시간 h:시간 시간:시간 "
"분:시간 min:시간 초:시간 sec:시간 일:시간 회:수 본:수 개:수 매:수 조:수 대:수 ea:수"
).split()
]
def fam(unit: Any) -> str | None:
u = unicodedata.normalize("NFKC", str(unit or "")).strip().lower().split("/")[0].strip()
if "%" in u:
return "율"
for a, f in FAM:
if u == a or u.startswith(a) and a not in ("m", "t", "h", "g"):
return f
return None
def unit_ok(tu: Any, cu: Any) -> bool:
a, b = fam(tu), fam(cu)
return a is None or b is None or a == b
_OPS = {
ast.Add: float.__add__,
ast.Sub: float.__sub__,
ast.Mult: float.__mul__,
ast.Div: float.__truediv__,
}
def _calc(node: ast.AST) -> float:
"""수 · 사칙 · 부호만(그 밖 = ValueError) — eval 없음."""
if isinstance(node, ast.Expression):
return _calc(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
x = _calc(node.operand)
return -x if isinstance(node.op, ast.USub) else x
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_calc(node.left), _calc(node.right))
raise ValueError(node)
def _arith(s: str) -> float | None:
"""「480 - 30」 · 「1/1.30」 → 수 · 못 셈 None."""
if not re.fullmatch(r"[\d.\s+\-*/()]+", s) or not re.search(r"\d", s):
return None
try:
return _calc(ast.parse(s.strip(), mode="eval"))
except (SyntaxError, ValueError, ZeroDivisionError):
return None
def nums_of(v: Any) -> list[float]:
"""칸 값 → [수] — 수 · [아래, 위] · 식 글자(셈 값 + 낱 수)."""
if isinstance(v, bool) or v is None:
return []
if isinstance(v, (int, float)):
return [float(v)]
if isinstance(v, list):
return [float(x) for x in v if isinstance(x, (int, float)) and not isinstance(x, bool)]
if isinstance(v, str):
got = _arith(v)
if got is None:
return []
parts = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", v)]
return [got] + [p for p in parts if p != got]
return []
# ── 직종(N5) ─────────────────────────────────────────────────────────
@lru_cache(maxsize=None)
def trade(name: str) -> str | None:
return est.trade(name)
def _flat(t: Any) -> str:
return re.sub(r"[\s()()_\-·.]", "", re.sub(r"\(.*?\)|_.*$", "", str(t or "")))
def canon(t: str) -> str:
return trade(t) or trade(_flat(t)) or _flat(t)
def same_trade(a: str, b: str) -> bool:
x, y = canon(a), canon(b)
return bool(x and y) and (x == y or len(y) >= 2 and len(x) >= 2 and (x in y or y in x))
# ── M01 읽기 · 로직 훑기 ─────────────────────────────────────────────
def folder() -> Path:
return Path(hb.m01.FOLDER)
def stamp() -> str:
"""M01 지문 — 첫 층 파일 내용(`M02_Hopyo_Judge._stamp` 와 같은 몫) + 보정 자료."""
files = {
p.name: derived.file_digest(p)
for p in sorted(folder().glob("*.json"))
if not p.name.startswith("_")
}
return derived.derived_key("san-basis-index", files, derived.file_digest(CORPUS))
def load(root: Path) -> tuple[dict, dict, dict, dict]:
tables, logic, eq = {}, {}, {}
for f in glob.glob(str(root / "소요량_*.json")) + glob.glob(str(root / "계수_*.json")):
for t in json.loads(Path(f).read_text(encoding="utf-8")).get("표", []):
tables[t["키"]] = t
for f in glob.glob(str(root / "로직_*.json")):
for it in json.loads(Path(f).read_text(encoding="utf-8"))["줄"]:
logic[it["키"]] = it
for r in json.loads((root / "기계.json").read_text(encoding="utf-8"))["줄"]:
eq[r["키"]] = r
by_no: dict = {}
for k, t in tables.items():
by_no.setdefault((k[:2], str(t.get("원문번호"))), []).append(k)
return tables, logic, eq, by_no
def _resolve(ref: str, tables: dict, by_no: dict) -> str | None:
if "{" in ref:
return None
if ref in tables:
return ref
tid, _, no = ref.partition(":")
hit = by_no.get((tid, no)) or []
return hit[0] if len(hit) == 1 else None
def scan(item: dict, tables: dict, by_no: dict) -> tuple:
"""로직 하나 → (찾기 부름, 식 속 수, 하위 로직 부름, 기계 부름, 호표 수량 속 수)."""
names = {s["이름"] for s in item.get("입력", [])}
finds, nums, calls, eqs = [], [], [], []
def arg_of(node):
if node[0] == "str":
return ("lit", node[1])
if node[0] == "num":
return ("lit", float(node[1]))
if node[0] == "name":
return ("name", node[1]) if node[1] in names else ("expr", node[1])
return ("expr", str(node)[:80])
def visit(node, field=None):
kind = node[0]
if kind == "field":
visit(node[1], node[2])
elif kind in ("find", "logic"):
if kind == "find" and (ref := _resolve(node[1], tables, by_no)):
finds.append((ref, field, {c: arg_of(a) for c, a in node[2]}))
if kind == "logic" and isinstance(node[1], str) and "{" not in node[1]:
calls.append((node[1], {c: arg_of(a) for c, a in node[2]}))
for _, a in node[2]:
if a[0] not in ("str", "num", "name"):
visit(a)
elif kind == "ref":
if node[1].startswith("EQ") and field:
eqs.append((node[1], field))
elif kind == "num":
nums.append(float(node[1]))
elif kind == "bin":
for side in (node[2], node[3]): # 비교 글자(「SCH == 20」)는 셈 수가 아님
if node[1] in mf._OPS and side[0] == "num":
continue
visit(side)
elif kind == "neg":
visit(node[1])
elif kind == "call":
for a in node[2]:
visit(a)
for text in ma._texts(item):
if any(text is h["수량"] for h in item.get("호표", [])):
continue
try:
visit(mf.parse(text))
except mf.FormulaError:
pass
trade_nums = [] # (호표 이름, 수량 식 속 수)
for h in item.get("호표", []):
before = len(nums)
try:
visit(mf.parse(h["수량"]))
except mf.FormulaError:
continue
trade_nums += [(h.get("이름") or "", x) for x in nums[before:]]
return finds, nums, calls, eqs, trade_nums
def _cells(tables: dict, eq: dict) -> list[dict]:
cells: list[dict] = []
for tk, t in tables.items():
vcols = t.get("값칸", {}) or {}
base = re.match(r"\s*(\d+(?:\.\d+)?)\s*(\S*)", str(t.get("기준") or ""))
if base and float(base.group(1)) not in (0, 1): # 「10㎡」 · 「100m」 당 — 표 기준 수(N6)
cells.append(
{
"v": float(base.group(1)),
"표": tk,
"줄": 0,
"칸": "기준",
"단위": base.group(2),
"종류": "기준",
}
)
for ri, row in enumerate(t.get("줄", [])):
got = {}
for col, unit in vcols.items():
for x in nums_of(row.get(col)):
cells.append(
{
"v": x,
"표": tk,
"줄": ri,
"칸": col,
"단위": row.get("단위") or unit,
"종류": "val",
}
)
got.setdefault(col, x)
for col, kind in (t.get("조건", {}) or {}).items():
x = row.get(col)
if kind == "수" and isinstance(x, (int, float)) and not isinstance(x, bool):
cells.append(
{"v": float(x), "표": tk, "줄": ri, "칸": col, "단위": "", "종류": "cond"}
)
for a, ua in vcols.items(): # 인/양 몫(N4 · 「5인 / 45㎡」 = 0.111)
if not str(row.get("단위") or ua).startswith(PERSON) or a not in got:
continue
for b, ub in vcols.items():
ub = str(row.get("단위") or ub)
if b == a or b not in got or ub.startswith(PERSON) or "%" in ub or not got[b]:
continue
cells.append(
{
"v": got[a] / got[b],
"표": tk,
"줄": ri,
"칸": f"{a}/{b}",
"단위": "인",
"종류": "ratio",
}
)
for ek, r in eq.items(): # 기계 요소 수(가격 · 글 뺌)
for col, x in (r.get("값") or {}).items():
if col in ("가격", "주연료종류", "조종원직종") or col.endswith("가격"):
continue
unit = (r.get("단위") or {}).get(col, "") if isinstance(r.get("단위"), dict) else ""
cells += [
{"v": y, "표": ek, "줄": 0, "칸": col, "단위": unit or "", "종류": "eq"}
for y in nums_of(x)
]
return cells
# ── 역색인 ───────────────────────────────────────────────────────────
class Index:
def __init__(self, root: Path | None = None):
self.tables, self.logic, self.eq, by_no = load(root or folder())
self.cells = _cells(self.tables, self.eq)
self.scans = {k: scan(it, self.tables, by_no) for k, it in self.logic.items()}
order = sorted(range(len(self.cells)), key=lambda i: self.cells[i]["v"])
self.vals = [self.cells[i]["v"] for i in order]
self.ids = order
self.uses = defaultdict(list) # 표 → [(로직, 부름id, 칸 | None, 거름, 묶음)]
self.eq_uses = defaultdict(list) # 기계 칸 → [(로직, 부름id, (고정 | 입력, 값))]
self.inline = defaultdict(set)
self.inline_trade = defaultdict(set)
self.eq_no = {str(r.get("원문번호")): k for k, r in self.eq.items()} | {
k: k for k in self.eq
}
self.callers = defaultdict(set) # 하위 → 부르는 위 로직(어느 깊이든)
for k in self.logic:
self._expand(k, k, {}, {}, 0, k)
self.inl_index = defaultdict(set)
for k, xs in self.inline.items():
for x in xs:
self.inl_index[round(x, 9)].add(k)
self.trade_index = defaultdict(set)
for k, xs in self.inline_trade.items():
for _, x in xs:
self.trade_index[round(x, 9)].add(k)
self.cells_of_table = defaultdict(list)
for c in self.cells:
self.cells_of_table[c["표"]].append(c)
self.item_tables = defaultdict(set)
for t, us in self.uses.items():
for u in us:
self.item_tables[u[0]].add(t)
self._hits: dict = {}
self._look: dict = {}
self._df: dict = {}
self.prior()
def _expand(self, top, key, rename, lit, depth, path):
finds, nums, calls, eqs, tnums = self.scans[key]
for name, x in tnums:
self.inline_trade[top].add((name, x))
for ref, field, args in finds:
filt, bind = {}, {}
for col, (kind, val) in args.items():
if kind == "lit":
filt[col] = val
elif kind == "name" and depth == 0:
bind[col] = val
elif kind == "name" and val in rename:
bind[col] = rename[val]
elif kind == "name" and val in lit:
filt[col] = lit[val]
else: # 중간 · 식 · 안 넘긴 입력 — 같은 인자를 쓰는 부름끼리 같은 줄(가짜 입력 §)
bind[col] = f"§{path}:{val}"
if not filt and not bind: # 조건 없는 부름 — 같은 표 부름끼리 같은 줄
bind["#row"] = f"§{path}:{ref}"
gid = f"{path}|{ref}|{sorted(filt.items(), key=str)}|{sorted(bind.items())}"
self.uses[ref].append((top, gid, field, filt, bind))
self.inline[top].update(nums)
for ref, field in eqs:
m = re.match(r"EQ:\{(\w+)\}", ref)
if m:
arg = m.group(1)
inputs = {sp["이름"] for sp in self.logic[key].get("입력", [])}
if depth == 0 and arg in inputs:
use = ("입력", arg)
elif depth > 0 and arg in rename:
use = ("입력", rename[arg])
elif depth > 0 and arg in lit and self.eq_no.get(str(lit[arg])):
use = ("고정", self.eq_no[str(lit[arg])])
else:
use = ("입력", f"§{path}:EQ:{arg}")
self.eq_uses[field].append((top, f"{path}@{ref}", use))
elif ref.startswith("EQ0"):
self.eq_uses[field].append((top, f"{path}@{ref}", ("고정", ref)))
if depth >= 3:
return
for j, (sub, args) in enumerate(calls):
if sub not in self.logic:
continue
self.callers[sub].add(top)
ren, lt = {}, {}
for name, (kind, val) in args.items():
if kind == "lit":
lt[name] = val
elif kind == "name":
if depth == 0:
ren[name] = val
elif val in rename:
ren[name] = rename[val]
elif val in lit:
lt[name] = lit[val]
self._expand(top, sub, ren, lt, depth + 1, f"{path}>{sub}.{j}")
@staticmethod
def _pass(row: dict, filt: dict) -> bool:
"""칸 하나가 줄 거름을 통과?"""
for col, want in filt.items():
have = row.get(col)
if have is None:
continue
if isinstance(have, list):
if isinstance(want, float) and len(have) == 2:
lo, hi = have
if (lo is not None and want < lo) or (hi is not None and want > hi):
return False
continue
if isinstance(want, float):
try:
if float(have) != want:
return False
except (TypeError, ValueError):
return False
elif str(have) != str(want):
return False
return True
@staticmethod
def _trade_ok(name: str, c: dict) -> bool:
if c["종류"] == "eq":
return False
if c["종류"] != "ratio" and fam(c["단위"]) not in (None, "사람"):
return False
return same_trade(name, c["칸"].split("/")[0])
def lookup(self, k: dict) -> list[tuple[int, str]]:
"""상수 → [(칸 id, 꼴 eq | pct | kgt)] — N1 · N2 · N3 · N4 · N5 · N8(캐시)."""
key = kkey(k)
if key not in self._look:
self._look[key] = self._lookup(k)
return self._look[key]
def _lookup(self, k: dict) -> list[tuple[int, str]]:
v, d, unit = k["v"], k["d"], k["단위"]
sig = len(re.sub(r"^0+|\.", "", f"{v:.{d}f}").lstrip("0"))
tol = 0.5 * 10 ** (-d) if d >= 1 and sig >= 2 else 1e-9
if k.get("몫"):
tol = abs(v) * 1e-6
forms = [(v, tol, "eq")]
tf = fam(unit)
if tf in (None, "율"):
forms += [(v * 100, tol * 100, "pct"), (v / 100, tol / 100, "pct")]
if tf == "무게":
forms += [(v * 1000, tol * 1000, "kgt"), (v / 1000, tol / 1000, "kgt")]
out = []
for x, t, how in forms:
t = max(t, abs(x) * 1e-9)
for j in range(
bisect.bisect_left(self.vals, x - t), bisect.bisect_right(self.vals, x + t)
):
cid = self.ids[j]
c = self.cells[cid]
if c["종류"] == "cond":
continue # 조건 칸(관경 · SCH)은 옵션 값 — 상수 설명에 안 씀
if k.get("직종") and not self._trade_ok(k["직종"], c):
continue
if c["종류"] == "ratio" and how != "eq":
continue
if how == "pct" and not (tf == "율" or "%" in str(c["단위"])):
continue
if how == "kgt" and not (tf == "무게" or fam(c["단위"]) == "무게"):
continue
if (
how == "eq"
and abs(c["v"] - v) > 1e-9
and c["종류"] != "ratio"
and tol < 1e-6
and not k.get("몫")
):
continue
if not unit_ok(unit, c["단위"]) and how == "eq":
continue
out.append((cid, how))
return out
def hits_of(self, cid: int) -> list[tuple]:
"""칸 → [(로직, 부름id, 줄, 묶음)](캐시)."""
got = self._hits.get(cid)
if got is None:
got = self._hits[cid] = self._hits_of(cid)
return got
def _hits_of(self, cid: int) -> list[tuple]:
c = self.cells[cid]
out = []
if c["종류"] == "기준":
return [(u[0], f"{u[1]}|기준", 0, {}) for u in self.uses.get(c["표"], [])]
if c["종류"] == "eq":
for item, callid, (kind, val) in self.eq_uses.get(c["칸"], []):
if kind == "고정" and val != c["표"]:
continue
out.append(
(
item,
callid,
c["표"],
{val: json.dumps(c["표"])} if kind == "입력" and val else {},
)
)
return out
row = self.tables[c["표"]]["줄"][c["줄"]]
cols = c["칸"].split("/") if c["종류"] == "ratio" else [c["칸"]]
for item, callid, field, filt, bind in self.uses.get(c["표"], []):
if field is not None and field not in cols and c["종류"] != "cond":
continue
if not self._pass(row, filt):
continue
b = {}
for col, name in bind.items():
if col == "#row":
b[name] = json.dumps(c["줄"])
elif row.get(col) is not None:
b[name] = json.dumps(row[col], ensure_ascii=False)
out.append((item, callid, c["줄"], b))
return out
def explain(self, k: dict) -> list[tuple]:
"""상수 하나 → [(로직, 부름id | None, 줄, 묶음, 꼴, 닻)] ·
닻 = 표 칸 · 직종 식 수로 맞음."""
out = []
for cid, how in self.lookup(k):
if self.cells[cid]["종류"] == "eq":
continue # 기계 요소 값(상각비계수 · 잡재료율 …)은 산근 글에 거의 안 나옴
out += [(item, call, row, bd, how, True) for item, call, row, bd in self.hits_of(cid)]
x = round(k["v"], 9)
if k.get("직종"):
for item in self.trade_index.get(x, ()):
if any(
same_trade(k["직종"], n) for n, y in self.inline_trade[item] if round(y, 9) == x
):
out.append((item, None, None, {}, "식", True))
else:
forms = (
(x, round(k["v"] * 100, 9), round(k["v"] / 100, 9))
if fam(k["단위"]) in (None, "율")
else (x,)
)
for form in forms:
for item in self.inl_index.get(form, ()):
if len(self.inline[item]) <= INLINE_MAX:
out.append((item, None, None, {}, "식", False))
return out
# ── 우연 보정 ────────────────────────────────────────────────────
def df(self, k: dict) -> int:
key = kkey(k)
if key not in self._df:
self._df[key] = max(1, len({x[0] for x in self.explain(k)}))
return self._df[key]
def prior(self) -> None:
"""보정 자료 상수마다 설명할 수 있는 로직 → 로직 몫 p · 평균 df."""
keys = [
dict(zip(("v", "d", "단위", "직종", "몫"), row))
for row in json.loads(CORPUS.read_text(encoding="utf-8"))
]
cnt: dict = defaultdict(int)
for k in keys:
for it in {x[0] for x in self.explain(k)}:
cnt[it] += 1
self.prom = {it: (cnt[it] + 1) / (len(keys) + 2) for it in self.logic}
self.mean_df = sum(self.df(k) for k in keys) / max(len(keys), 1)
def q_of(self, item: str, k: dict) -> float:
"""로직 item 이 상수 k 를 우연히 설명할 확률 ≈ df_k · p_item / 평균 df."""
p = self.prom.get(item, 1 / max(len(self.prom), 1))
return min(0.95, max(1e-6, self.df(k) * p / self.mean_df))
def kkey(k: dict) -> tuple:
return (k["v"], k["d"], k["단위"], k.get("직종"), bool(k.get("몫")))
def pb_sig(qs: list[float], c: int) -> float:
"""포아송 이항 꼬리 P(X ≥ c) 의 -log10."""
if c <= 0:
return 0.0
dp = [1.0] + [0.0] * len(qs)
for q in qs:
for j in range(len(dp) - 1, 0, -1):
dp[j] = dp[j] * (1 - q) + dp[j - 1] * q
dp[0] *= 1 - q
return round(-math.log10(max(sum(dp[c:]), 1e-300)), 2)
_LOCK = threading.Lock()
_CACHE: dict[str, Index] = {}
def get() -> Index:
"""M01 지문마다 한 번 지은 역색인(프로세스 안 · 지문 바뀌면 다시)."""
key = stamp()
with _LOCK:
got = _CACHE.get(key)
if got is None:
trade.cache_clear()
_CACHE.clear()
got = _CACHE[key] = Index()
return got
def warm() -> None:
"""서버 시작 때 한 번(뒤에서) — 실패 = 다음 부름에서 다시."""
try:
get()
except Exception: # noqa: BLE001
pass