Files
Aislo/M02_MasterTemplete/M02_Hopyo_Option.py
T
eomsangdonandClaude Opus 5.5 e9e949112c feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — 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
2026-10-15 23:36:23 +09:00

498 lines
21 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-5): 고른 우리 로직의 입력 칸마다
후보(옵션 공간)를 엔진으로 돌려 엑셀 품(직종 품 · 장비 시간 · 자재 수량)과 같은 입력을 찾음.
공간 = 고르기 모두 · 기계(EQ) = 엑셀 규격 수와 맞는 기종(없으면 모두) · 범위 = 두 끝 + 범위 안 엑셀
수 ·
표 찾기 칸 = 그 표 줄 값 · 그 밖 수 = 자동값 + 같은 이름 계수 + 단위 붙은 엑셀 수(% 칸 = 0 덧 ·
「품」 칸 = 엑셀 품 × 결과단위) · 뺌 = 지역 · 보정작업 · 조종원직종.
조합 = 곱 ≤ CAP 모두(안 닿는 칸 · 같은 품 값 묶음 셈) · 넘으면 엑셀 조건 낱말로 고르기 좁힘 ·
그래도 넘으면 기준점 둘에서 하나씩 + 고르기 둘씩.
수준 = 0(0.5%) · 1(R = 0.5% + 엑셀 값 자릿수 반올림 폭) · 2(3%) · 9(없음) — 「딱 맞음」(핵심 + 엑셀
몫을
다 덮음)만 셈 · 같은 수준이면 거리 작은 것. 규칙 · 문턱은 336 K5(`k5_lib` · `stage_b`) 그대로.
엔진 = `M02_Hopyo_Ours.run`(읽기만).
"""
from __future__ import annotations
import itertools
import json
import math
import re
from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Fit as fit # 손잡이(CAP · TOL) — 부를 때만 씀
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete.M02_Hopyo_Words import nfkc, specnums
FIXED = ("지역", "보정작업", "조종원직종")
_NUM = re.compile(r"(?<![\d,.])(\d+(?:\.\d+)?)(?![\d,])")
UNITS = {
"%": ("%",),
"㎝": ("㎝", "cm"),
"cm": ("㎝", "cm"),
"㎞": ("㎞", "km"),
"km": ("㎞", "km"),
"㎜": ("㎜", "mm"),
"mm": ("㎜", "mm"),
"㎥": ("㎥", "m3"),
"㎡": ("㎡", "m2"),
"m": ("m",),
"회": ("회",),
"개월": ("개월",),
"층": ("층",),
"t": ("t", "ton", "톤"),
"ton": ("t", "ton", "톤"),
"톤": ("t", "ton", "톤"),
"분": ("분",),
"초": ("초", "sec"),
"kg": ("kg", "㎏"),
"㎏": ("kg", "㎏"),
}
UNITW = {"mm", "cm", "kg", "ton", "sec", "kgf", "mpa"}
# ── 엑셀 쪽 수 ─────────────────────────────────────────────────────────
def ex_of(efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
"""엑셀 지문 → 옵션 공간 재료(글 · 글머리 · 계수 · 규격 수 · 조건 · 품 값)."""
texts = [efp["이름"], efp.get("규격") or ""] + [str(t) for t in text or []]
sp: set[float] = set()
for m in efp["장비"]:
sp |= set(m.get("규격") or [])
sp |= specnums(f"{efp['이름']} {efp.get('규격') or ''}")
return {
"글": texts,
"글머리": texts[:8],
"계수": efp.get("계수") or {},
"규격수": sp,
"조건": efp.get("조건") or {},
"품값": {Decimal(str(x["품"])) for x in efp["인력"].values() if x.get("품")},
}
def excel_numbers(texts: list[str], spec: dict[str, Any] | None = None) -> set[Decimal]:
"""글의 수 — spec 이 있으면 그 칸 단위가 뒤에 붙거나 칸 이름이 앞에 붙은 수만(쉼표 단가 뺌)."""
out: set[Decimal] = set()
flat = [nfkc(t) for t in texts]
if spec is None:
pats = [_NUM]
else:
u = nfkc(spec.get("단위")).strip().lower()
vs = UNITS.get(u, (u,) if u and len(u) <= 3 and "/" not in u else ())
vs = () if u == "%" else vs # 「노무비의 2%」 같은 율이 감률 칸 후보로 드는 것을 막음
num = r"(\d+(?:\.\d+)?)"
pats = []
if vs:
alt = "|".join(re.escape(x) for x in vs)
pats.append(
re.compile(r"(?<![\d,.])" + num + r"\s*(?:" + alt + r")(?![A-Za-z가-힣])", re.I)
)
pats.append(
re.compile(re.escape(nfkc(spec["이름"])) + r"\s*[:=(]?\s*" + num + r"(?![\d,])")
)
for t in flat:
for pat in pats:
for n in pat.findall(t):
v = Decimal(n)
if 0 < v <= 2000:
out.add(v.normalize() if v == v.to_integral() else v)
return out
def eq_numbers(code: str) -> set[float]:
"""기계 규격 수 — 단위 붙은 수 + 맨 수(「41 [80∼95]」 → 41 · 80 · 95)."""
one = ours.whole().numbers.get("EQ", {}).get(str(code)) or {}
bare = {float(n) for n in re.findall(r"\d+(?:\.\d+)?", nfkc(one.get("규격")))}
return ours.eq_of(code)[1] | bare
def _uses(row: dict) -> tuple[dict, set]:
try:
return m01.mau._uses(row)
except Exception: # noqa: BLE001 — 식을 못 읽는 로직은 찾기 칸 없음
return {}, set()
def space(code: str, ex: dict[str, Any] | None = None) -> list[tuple[str, list, str]]:
"""[(입력 이름, 후보, 갈래)] — 갈래 = 기계 · 고르기 · 범위 · 표 · 수."""
whole = ours.whole()
row = whole.logic(code)
auto = m01.mau.values(whole, row)
finds, _ = _uses(row)
ex = ex or {}
texts = ex.get("글") or []
coef = {k.lower(): Decimal(str(v)) for k, v in (ex.get("계수") or {}).items()}
spn = ex.get("규격수") or set()
times = Decimal(str(ours.times_of(row)))
out = []
for s in row.get("입력") or []:
name, opts = s["이름"], s.get("고르기") or []
if name in FIXED:
continue
if opts and all(isinstance(v, str) and ours.EQ_CODE.match(v) for v in opts):
hits = {v: len(eq_numbers(v) & spn) for v in opts} if spn else {}
most = max(hits.values(), default=0)
keep = [
v for v in opts if most and hits[v] == most
] # 엑셀 규격 수가 가장 많이 겹치는 기종
out.append((name, keep or list(opts), "기계"))
elif opts:
out.append((name, list(opts), "고르기"))
elif isinstance(s.get("범위"), list) and len(s["범위"]) == 2:
lo, hi = (Decimal(str(x)) for x in s["범위"])
vals = {lo, hi} | {v for v in excel_numbers(texts, s) if lo <= v <= hi}
out.append((name, sorted(vals), "범위"))
elif name in finds:
rows = set()
for ref, cond in finds[name]:
rows |= set(m01.mau._fits(whole, ref, cond))
out.append((name, sorted(rows) or [auto.get(name)], "표"))
else:
base = auto.get(name)
vals = {base} if base is not None else set()
if name.lower() in coef:
vals.add(coef[name.lower()])
vals |= excel_numbers(texts, s) if texts else set()
if str(s.get("단위") or "").strip() == "%":
vals.add(Decimal(0))
if name == "품":
vals |= {v * times for v in ex.get("품값", set())}
vals.discard(None)
out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수"))
return out
def _product(sp: list) -> list[dict]:
names = [n for n, _, _ in sp]
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o, _ in sp])]
def narrow(sp: list, ex: dict[str, Any] | None) -> list:
"""고르기 후보를 엑셀 조건 낱말로 좁힘 — 낱말이 든 후보가 있으면 그것만."""
words = {w for ws in ((ex or {}).get("조건") or {}).values() for w in ws}
flat = re.sub(r"\s+", "", nfkc(" ".join((ex or {}).get("글머리") or [])))
out = []
for name, opts, kind in sp:
if kind == "고르기" and len(opts) > 1:
hit = [
o
for o in opts
if any(w in str(o) for w in words) or (len(str(o)) >= 2 and str(o) in flat)
]
if hit:
out.append((name, hit, kind))
continue
out.append((name, opts, kind))
return out
def modes() -> tuple[tuple[str, float, bool], ...]:
"""허용 셋 — 0.5% · R(0.5% + 엑셀 값 자릿수 반올림 폭) · 3%."""
return (("0.5%", fit.TOL[0], False), ("R", fit.TOL[0], True), ("3%", fit.TOL[2], False))
def combos(
sp: list, ex: dict[str, Any] | None = None, cap: int | None = None
) -> tuple[list[dict], str]:
"""(조합, 어떻게 — 모두 · 좁힘 · 하나씩)."""
cap = cap or fit.CAP
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= cap:
return _product(sp), "모두"
sp2 = narrow(sp, ex)
if math.prod(len(o) for _, o, _ in sp2) <= cap:
return _product(sp2), "좁힘"
base = {n: o[0] for n, o, _ in sp2}
out = [dict(base)] + [{**base, n: v} for n, o, _ in sp2 for v in o[1:]]
small = [(n, o) for n, o, k in sp2 if k != "수"]
for (n1, o1), (n2, o2) in itertools.combinations(small, 2):
if len(o1) * len(o2) <= 400:
out += [{**base, n1: a, n2: b} for a in o1 for b in o2]
seen, uniq = set(), []
for c in out:
k = json.dumps(c, sort_keys=True, default=str, ensure_ascii=False)
if k not in seen:
seen.add(k)
uniq.append(c)
return uniq[:cap], "하나씩"
def sig(v: dict | None, nd: int = 4) -> str | None:
"""품 꼴 서명 — 직종 · 장비(갈래 · 시간) · 자재 · 딸린 로직(유효 nd 자리)."""
if v is None:
return None
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items() if q),
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"] if q),
sorted((k, r(q)) for k, q in v["M"].items() if q),
sorted((k, r(q)) for k, q in v["S"].items() if q),
],
ensure_ascii=False,
)
def plan(code: str, ex: dict[str, Any] | None = None) -> tuple[list[dict], str, list, list, dict]:
"""(조합, 어떻게, 움직이는 칸, 안 닿는 칸, 값 묶음)."""
sp = space(code, ex)
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= fit.CAP:
cb = _product(sp)
sg = [sig(ours.run(code, c)) for c in cb]
active, inert, cls = [], [], {}
for n, o, k in sp:
if len(o) < 2:
continue
ctx: dict[str, dict] = {}
for c, s_ in zip(cb, sg, strict=True):
rest = {x: str(y) for x, y in c.items() if x != n}
ctx.setdefault(json.dumps(rest, sort_keys=True, ensure_ascii=False), {})[
str(c[n])
] = s_
keys = sorted(ctx)
prof = {str(v): tuple(ctx[g].get(str(v)) for g in keys) for v in o}
if len(set(prof.values())) == 1:
inert.append(n)
continue
active.append(n)
if k != "고르기":
ids: dict = {}
cls[n] = {v: ids.setdefault(pf, len(ids)) for v, pf in prof.items()}
return cb, "모두", active, inert, cls
base = {n: o[0] for n, o, _ in sp}
alt = {n: o[-1] for n, o, _ in sp}
act, inert = [], []
for n, o, k in sp:
if len(o) == 1:
continue
s0 = {sig(ours.run(code, {**base, n: v})) for v in o}
s1 = {sig(ours.run(code, {**alt, n: v})) for v in o} if len(s0) == 1 else s0
if len(s0) == 1 and len(s1) == 1:
inert.append(n)
continue
if k != "고르기": # 같은 표 줄에 드는 수는 한 값으로 — 두 기준점 품이 같으면 같은 값
keep, seen = [], set()
for v in o:
pair = (sig(ours.run(code, {**base, n: v})), sig(ours.run(code, {**alt, n: v})))
if pair not in seen:
seen.add(pair)
keep.append(v)
o = keep
act.append((n, o, k))
cb, how = combos(act, ex)
return [{**base, **c} for c in cb], how, [n for n, _, _ in act], inert, {}
def vary(hits: list[dict], act: list[str], cls: dict) -> list[str]:
"""맞은 조합들 사이에서 값이 갈리는 칸 — 수 칸은 같은 값 묶음이면 한 값."""
return [
n for n in act if len({cls.get(n, {}).get(str(g.get(n)), str(g.get(n))) for g in hits}) > 1
]
# ── 맞대기 ──────────────────────────────────────────────────────────────
def near(a: Any, b: Any, t: float, r: float = 0.0) -> bool:
"""a(엑셀) ≈ b(우리) — 비 t 안 · r = 엑셀 값 자릿수 반올림 폭."""
return bool(a and b and a > 0 and b > 0 and abs(a - b) <= max(t * max(a, b), r))
def ru(x: float) -> float:
"""엑셀 값 반올림 폭 — 소수 둘째~넷째 자리로 적힌 값만 · 값의 4% 안."""
s = repr(float(x))
if "e" in s or "." not in s:
return 0.0
d = len(s.split(".")[1].rstrip("0"))
return min(0.5 * 10 ** (-d), 0.04 * float(x)) if 2 <= d <= 4 else 0.0
def mat_words(name: Any) -> set[str]:
"""자재 이름 낱말 — 한글 두 자 넘는 덩이 · 영문 세 자 넘는 덩이 · 단위 낱말 뺌."""
t = nfkc(name).lower().replace(".", "")
return {w for w in re.findall(r"[가-힣]{2,}|[a-z]{3,}", t) if w not in UNITW}
def _named(ws: set[str], k: str) -> bool:
w = mat_words(k)
return bool(ws & w) or any(a in b or b in a for a in ws for b in w)
def compare(
e: dict, v: dict | None, t: float, base: bool = False, rnd: bool = False
) -> dict | None:
"""엑셀 지문 ↔ 우리 셈 → {직종, 장비, 자재, 핵심, 직종만, 덧} · base = 엑셀 밑품으로."""
if v is None:
return None
tol = lambda x: ru(x) if rnd else 0 # noqa: E731
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
ov = {k: q for k, q in v["L"].items() if q and q > 0}
out: dict[str, Any] = {"덧": []}
if E:
same = set(E) == set(ov)
ok = same and all(near(E[k], ov[k], t, tol(E[k])) for k in E)
out["직종"] = "같음" if ok else "직종 같음" if same else "다름"
else:
out["직종"] = "우리만" if ov else "없음"
EX = [m for m in e["장비"] if m.get("시간")]
OX = [(set(a), q) for a, _, q in v["X"] if q]
if EX and OX:
ok = []
for m in EX:
mine = [q for a, q in OX if a & set(m["갈래"])]
if not mine:
out["덧"].append("엑셀만 장비 " + "·".join(sorted(m["갈래"])))
continue
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
hit = any(near(h, q, t, tol(h)) for q in mine) or near(h, sum(mine), t, tol(h))
ok.append(hit)
et = set().union(*[set(m["갈래"]) for m in e["장비"]])
out["덧"] += ["우리만 장비 " + "·".join(sorted(a)) for a, _ in OX if not a & et]
out["장비"] = ("같음" if all(ok) else "다름") if ok else "갈래 안 겹침"
else:
out["장비"] = "엑셀만" if EX else "우리만" if OX else "없음"
EM = [m for m in e["자재"] if m.get("품")]
OM = {k: q for k, q in v["M"].items() if q}
if EM and OM:
ok = []
for m in EM:
mine = [q for k, q in OM.items() if _named(mat_words(m["이름"]), k)]
if mine:
x = m["밑품"] if base and m.get("밑품") else m["품"]
ok.append(any(near(x, q, t, tol(x)) for q in mine))
out["자재"] = ("같음" if all(ok) else "다름") if ok else "이름 안 겹침"
else:
out["자재"] = "엑셀만" if EM else "없음"
core = [out["직종"] == "같음"] if E else [False] if ov else []
core += [out[k] == "같음" for k in ("장비", "자재") if out[k] in ("같음", "다름")]
out["핵심"] = bool(core) and all(core)
out["직종만"] = bool(E) and out["직종"] == "같음"
return out
def strong(e: dict, c: dict | None) -> bool:
"""딱 맞음 — 핵심 + 엑셀 몫을 다 덮음(엑셀만 · 우리만 장비 없음 · 자재 다름 없음 ·
「보통인부 하나뿐 · 장비 없음」 줄 아님)."""
if not c or not c.get("핵심"):
return False
if c.get("장비") in ("엑셀만", "우리만", "갈래 안 겹침"):
return False
if any("엑셀만 장비" in d or "우리만 장비" in d for d in c.get("덧") or []):
return False
if c.get("자재") == "다름":
return False
labor = [k for k, x in e["인력"].items() if x.get("품")]
return not (labor == ["보통인부"] and not any(m.get("시간") for m in e["장비"]))
def gap(e: dict, v: dict | None, base: bool = False) -> float:
"""닮음 거리 — 직종 묶음 어긋남 + 품 |ln 비| 평균 + 장비 시간 반 무게."""
if v is None:
return 99.0
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
ov = {k: q for k, q in v["L"].items() if q and q > 0}
d = 0.0
if E or ov:
d += len(set(E) ^ set(ov)) / len(set(E) | set(ov))
both = [min(1.0, abs(math.log(E[k] / ov[k]))) for k in set(E) & set(ov)]
d += sum(both) / len(both) if both else 1.0
EX = [m for m in e["장비"] if m.get("시간")]
OX = [(set(a), q) for a, _, q in v["X"] if q]
if EX and OX:
hs = []
for m in EX:
mine = [q for a, q in OX if a & set(m["갈래"])]
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
hs.append(min(1.0, min(abs(math.log(h / q)) for q in mine)) if mine else 1.0)
d += 0.5 * sum(hs) / len(hs)
return d
def csig(e: dict, v: dict | None, nd: int = 4) -> str | None:
"""엑셀이 보이는 몫만의 품 서명 — 직종 · 엑셀 갈래와 겹치는 장비 · 엑셀 이름과 겹치는 자재."""
if v is None:
return None
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set()
em = [mat_words(m["이름"]) for m in e["자재"] if m.get("품")]
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items() if q),
sorted((tuple(a), r(q)) for a, _, q in v["X"] if q and set(a) & et),
sorted((k, r(q)) for k, q in v["M"].items() if q and any(_named(ws, k) for ws in em)),
],
ensure_ascii=False,
)
def ours_qty(v: dict | None) -> dict[str, Any]:
"""우리 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
if v is None:
return {"인력": {}, "장비": [], "자재": []}
return {
"인력": {k: round(q, 6) for k, q in v["L"].items() if q},
"장비": [{"갈래": list(a), "시간": round(q, 6)} for a, _, q in v["X"] if q],
"자재": [{"이름": k, "수량": round(q, 6)} for k, q in v["M"].items() if q],
}
def _plain(value: Any) -> Any:
if isinstance(value, Decimal):
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
return value
def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
"""로직 하나의 옵션 거꾸로 찾기 → {수준, 판정, 입력, 갈리는칸, 견줌, 우리품, 거리, 섞기전}."""
none = {"수준": 9, "판정": "없음", "입력": {}, "갈리는칸": [], "견줌": None}
none.update(우리품=ours_qty(None), 거리=99.0, 섞기전=False, 직종같음=False)
try:
ex = ex_of(efp, text)
cb, how, act, _, cls = plan(code, ex)
except Exception: # noqa: BLE001 — 로직 없음 · 식 못 읽음
return {**none, "판정": "못 셈"}
best = (9, 9, 99.0, None, None)
hits: dict[int, list] = {0: [], 1: [], 2: []}
for g in cb:
v = ours.run(code, g)
if v is None or not (v["L"] or v["X"] or v["M"]):
continue
lvl = weak = 9
for i, (_, t, rnd) in enumerate(modes()):
c = compare(efp, v, t, rnd=rnd) or {}
if c.get("핵심"):
weak = min(weak, i)
if strong(efp, c):
hits[i].append((g, csig(efp, v)))
lvl = min(lvl, i)
d = gap(efp, v)
if (lvl, weak, d) < best[:3]:
best = (lvl, weak, d, g, v)
lvl, _, d, g, v = best
if g is None:
return none
got = [h for h in hits.get(lvl, [])] if lvl < 9 else []
split = vary([h for h, _ in got], act, cls)
if not got:
verdict = "없음"
elif len({s for _, s in got}) > 1:
verdict = "여럿(다른 품)"
else:
verdict = "여럿(같은 품)" if split else "하나만"
auto = m01.mau.values(ours.whole(), ours.whole().logic(code)) # 움직인 칸 + 자동값과 다른 칸
base = compare(efp, v, fit.TOL[2], base=True) or {}
labor = {k for k, x in efp["인력"].items() if x.get("품")}
return {
"수준": lvl,
"판정": verdict,
"입력": {n: _plain(x) for n, x in g.items() if n in act or str(x) != str(auto.get(n))},
"모든입력": {n: _plain(x) for n, x in g.items()},
"갈리는칸": split,
"견줌": compare(efp, v, fit.TOL[2]),
"직종같음": bool(v["L"]) and set(k for k, q in v["L"].items() if q) == labor,
"우리품": ours_qty(v),
"거리": round(d, 4),
"섞기전": lvl == 9 and bool(base.get("핵심")),
"어떻게": how,
}