pino-source-code / src /pino /substitution_v11.py
Matthew Ford
feat: low-evidence gating in substitution_v11 + wisemoor description provenance
cc86172
Raw
History Blame Contribute Delete
20.7 kB
from __future__ import annotations
import hashlib
import json
import math
from collections import Counter
from pathlib import Path
from typing import Any, Iterable
import numpy as np
PIMT_V11 = "v11"
ANSWER_SHAPES = {"single_material", "any_of", "accord"}
# Minimum mutually-observed scoring components for a row to count as a
# trustworthy retrieval. Below this the rank is driven mostly by priors /
# file order, so it is reported as low-evidence, not a model miss or hit.
MIN_OBSERVED_COMPONENTS = 2
def load_jsonl(path: str | Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in Path(path).open(encoding="utf-8") if line.strip()]
def write_jsonl(path: str | Path, rows: Iterable[dict[str, Any]]) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"".join(json.dumps(row, sort_keys=True, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
def wilson_interval(successes: int, total: int, z: float = 1.959963984540054) -> dict[str, Any]:
"""Return count-first 95% Wilson score reporting for a binomial proportion."""
if total < 0 or successes < 0 or successes > total:
raise ValueError("Wilson counts must satisfy 0 <= successes <= total")
if total == 0:
return {"successes": successes, "total": total, "wilson_95": None}
proportion = successes / total
z2 = z * z
denominator = 1.0 + z2 / total
center = (proportion + z2 / (2.0 * total)) / denominator
half_width = z * math.sqrt(
(proportion * (1.0 - proportion) + z2 / (4.0 * total)) / total
) / denominator
return {
"successes": successes,
"total": total,
"wilson_95": [max(0.0, center - half_width), min(1.0, center + half_width)],
}
def explicit_eval_row(row: dict[str, Any]) -> dict[str, Any]:
"""Upgrade a legacy one-substitute row to the explicit v11.1 answer schema."""
if "answer" in row:
return row
return {
**row,
"answer": {
"shape": "single_material",
"materials": [{"name": row.get("substitute"), "cas": row.get("substitute_cas")}],
},
}
def _answer(row: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
answer = explicit_eval_row(row)["answer"]
shape = answer.get("shape")
materials = answer.get("materials") or []
if shape not in ANSWER_SHAPES:
raise ValueError(f"unsupported answer shape for {row.get('pair_id')}: {shape!r}")
if not materials or any(not (item.get("cas") or item.get("name")) for item in materials):
raise ValueError(f"answer materials missing for {row.get('pair_id')}")
if shape == "single_material" and len(materials) != 1:
raise ValueError(f"single_material answer must contain exactly one material: {row.get('pair_id')}")
return str(shape), materials
def normalize_name(value: str | None) -> str:
if not value:
return ""
keep = []
for ch in value.lower():
keep.append(ch if ch.isalnum() else " ")
return " ".join("".join(keep).split())
def profile_key(profile: dict[str, Any]) -> str:
return str(profile.get("cas") or profile.get("profile_id") or normalize_name(profile.get("name")))
def descriptor_distribution_from_codes(codes: Iterable[str]) -> dict[str, float]:
counts = Counter(code for code in codes if code)
total = sum(counts.values())
if not total:
return {}
return {code: count / total for code, count in sorted(counts.items())}
def cosine_similarity(left: dict[str, float], right: dict[str, float]) -> float:
keys = sorted(set(left) | set(right))
if not keys:
return 0.0
a = np.asarray([left.get(key, 0.0) for key in keys], dtype=float)
b = np.asarray([right.get(key, 0.0) for key in keys], dtype=float)
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
if denom <= 0:
return 0.0
return float(np.dot(a, b) / denom)
def numeric_proximity(a: Any, b: Any, scale: float) -> float | None:
try:
fa = float(a)
fb = float(b)
except (TypeError, ValueError):
return None
if not (math.isfinite(fa) and math.isfinite(fb)):
return None
return float(math.exp(-abs(fa - fb) / scale))
def note_role(profile: dict[str, Any]) -> str:
role = profile.get("poucher_tier")
if role in {"top", "heart", "mid", "base"}:
return "heart" if role == "mid" else str(role)
log_vp = profile.get("log10_vapor_pressure_pa")
if log_vp is None:
return "unknown"
try:
value = float(log_vp)
except (TypeError, ValueError):
return "unknown"
if value >= 2.0:
return "top"
if value <= 0.0:
return "base"
return "heart"
def role_score(left: dict[str, Any], right: dict[str, Any]) -> float | None:
l_role = note_role(left)
r_role = note_role(right)
if "unknown" in {l_role, r_role}:
return None
return 1.0 if l_role == r_role else 0.25
def profile_lookup(profiles: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for profile in profiles:
keys = {profile_key(profile), normalize_name(profile.get("name"))}
cas = profile.get("cas")
if cas:
keys.add(str(cas))
for alias in profile.get("aliases") or []:
keys.add(normalize_name(alias))
for key in keys:
if key:
out[key] = profile
return out
MODE_WEIGHTS: dict[str, dict[str, float]] = {
"odor_match": {"odor": 0.70, "substantivity": 0.10, "volatility": 0.10, "role": 0.10},
"function_match": {"odor": 0.25, "substantivity": 0.25, "volatility": 0.25, "role": 0.25},
"evaporation_match": {"odor": 0.10, "substantivity": 0.35, "volatility": 0.40, "role": 0.15},
"regulatory_match": {"odor": 0.30, "substantivity": 0.20, "volatility": 0.20, "role": 0.15, "constraints": 0.15},
"cost_match": {"odor": 0.35, "substantivity": 0.20, "volatility": 0.20, "role": 0.15, "constraints": 0.10},
"accord_rebuild": {"odor": 0.35, "substantivity": 0.20, "volatility": 0.20, "role": 0.25},
}
MODE_REQUIRED_FIELDS = {
"regulatory_match": ("regulatory_status", "ifra_restrictions", "allergens"),
"cost_match": ("cost_tier", "price", "availability_tier"),
}
def mode_supported(mode: str, target: dict[str, Any], profiles: Iterable[dict[str, Any]]) -> bool:
"""Whether a mode has at least target and catalogue evidence for its advertised axis."""
fields = MODE_REQUIRED_FIELDS.get(mode)
if not fields:
return True
return any(target.get(field) is not None for field in fields) and any(
any(profile.get(field) is not None for field in fields) for profile in profiles
)
def mode_axis_similarity(mode: str, target: dict[str, Any], candidate: dict[str, Any]) -> float | None:
"""Compare observed mode-specific fields; never manufacture a neutral value."""
if mode == "cost_match":
numeric = numeric_proximity(target.get("price"), candidate.get("price"), 1.0)
if numeric is not None:
return numeric
for field in ("cost_tier", "availability_tier"):
if target.get(field) is not None and candidate.get(field) is not None:
return 1.0 if target[field] == candidate[field] else 0.0
if mode == "regulatory_match":
scores = []
if target.get("regulatory_status") is not None and candidate.get("regulatory_status") is not None:
scores.append(1.0 if target["regulatory_status"] == candidate["regulatory_status"] else 0.0)
for field in ("ifra_restrictions", "allergens"):
left, right = set(target.get(field) or []), set(candidate.get(field) or [])
if left or right:
scores.append(len(left & right) / len(left | right))
if scores:
return sum(scores) / len(scores)
return None
def score_candidate(
target: dict[str, Any],
candidate: dict[str, Any],
mode: str = "function_match",
constraints: dict[str, Any] | None = None,
) -> dict[str, Any]:
constraints = constraints or {}
banned = {normalize_name(v) for v in constraints.get("banned_materials", [])}
banned.update(str(v) for v in constraints.get("banned_cas", []))
c_key = profile_key(candidate)
if c_key == profile_key(target):
return {"score": -1.0, "reasons": {"self": True}}
if c_key in banned or normalize_name(candidate.get("name")) in banned:
return {"score": -1.0, "reasons": {"banned": True}}
target_odor = target.get("descriptor_distribution") or {}
candidate_odor = candidate.get("descriptor_distribution") or {}
components: dict[str, float | None] = {
"odor": cosine_similarity(target_odor, candidate_odor) if target_odor and candidate_odor else None,
"substantivity": numeric_proximity(
target.get("substantivity_log10_predicted"),
candidate.get("substantivity_log10_predicted"),
0.45,
),
"volatility": numeric_proximity(target.get("log10_vapor_pressure_pa"), candidate.get("log10_vapor_pressure_pa"), 1.0),
"role": role_score(target, candidate),
"constraints": mode_axis_similarity(mode, target, candidate),
}
weights = MODE_WEIGHTS.get(mode, MODE_WEIGHTS["function_match"])
observed = {name: value for name, value in components.items() if value is not None and name in weights}
observed_weight = sum(weights[name] for name in observed)
score = sum(float(value) * weights[name] for name, value in observed.items()) / observed_weight if observed_weight else 0.0
return {
"score": float(score),
"reasons": components,
"evidence_coverage": observed_weight / sum(weights.values()),
"observed_components": sorted(observed),
}
def substitute(
target: str,
profiles: list[dict[str, Any]],
constraints: dict[str, Any] | None = None,
mode: str = "function_match",
top_k: int = 5,
) -> list[dict[str, Any]]:
lookup = profile_lookup(profiles)
target_profile = lookup.get(str(target)) or lookup.get(normalize_name(target))
if target_profile is None:
raise KeyError(f"target material not found in v11 profile table: {target}")
ranked = []
for candidate in profiles:
scored = score_candidate(target_profile, candidate, mode=mode, constraints=constraints)
if scored["score"] < 0:
continue
ranked.append(
{
"candidate": candidate.get("name"),
"candidate_cas": candidate.get("cas"),
"score": scored["score"],
"components": scored["reasons"],
"evidence_coverage": scored["evidence_coverage"],
"observed_components": scored["observed_components"],
"note_role": note_role(candidate),
}
)
ranked.sort(
key=lambda row: (
-row["score"],
-row["evidence_coverage"],
str(row.get("candidate_cas") or ""),
normalize_name(row.get("candidate")),
)
)
return ranked[:top_k]
def evaluate_material(material: str, profiles: list[dict[str, Any]]) -> dict[str, Any]:
lookup = profile_lookup(profiles)
profile = lookup.get(str(material)) or lookup.get(normalize_name(material))
if profile is None:
raise KeyError(f"material not found in v11 profile table: {material}")
return {
"pimt_version": PIMT_V11,
"type": "material",
"material": profile.get("name"),
"cas": profile.get("cas"),
"odor_family_fit": profile.get("descriptor_distribution") or {},
"predicted_substantivity_log10": profile.get("substantivity_log10_predicted"),
"volatility": {
"vapor_pressure_pa": profile.get("vapor_pressure_pa"),
"log10_vapor_pressure_pa": profile.get("log10_vapor_pressure_pa"),
"boiling_point_k": profile.get("boiling_point_k"),
"note_role": note_role(profile),
},
"substitution_risks": profile.get("substitution_risks") or [],
"enrichment_pending": profile.get("enrichment_pending") or [],
}
def evaluate_formula(formula: list[dict[str, Any]], profiles: list[dict[str, Any]]) -> dict[str, Any]:
lookup = profile_lookup(profiles)
materials = []
missing = []
total_weight = 0.0
role_weights = Counter()
for item in formula:
name = item.get("cas") or item.get("name")
profile = lookup.get(str(name)) or lookup.get(normalize_name(str(name)))
if profile is None:
missing.append(name)
continue
weight = float(item.get("weight_fraction", item.get("percent", 0.0)) or 0.0)
total_weight += weight
role_weights[note_role(profile)] += weight
materials.append(evaluate_material(str(name), profiles) | {"input_weight": weight})
return {
"pimt_version": PIMT_V11,
"type": "formula",
"n_materials": len(materials),
"missing_materials": missing,
"total_weight": total_weight,
"top_heart_base_balance": dict(role_weights),
"materials": materials,
"substitution_risks": ["safety/use-level flags enrichment-pending; no safety claims emitted"],
}
def evaluate_retriever(
eval_rows: list[dict[str, Any]],
profiles: list[dict[str, Any]],
mode: str | None = None,
top_k: int = 5,
ranking_strategy: str = "model",
) -> dict[str, Any]:
if ranking_strategy not in {"model", "random"}:
raise ValueError(f"unknown ranking strategy: {ranking_strategy}")
lookup = profile_lookup(profiles)
evaluated = []
excluded = []
mode_counts = Counter()
for legacy_row in eval_rows:
row = explicit_eval_row(legacy_row)
shape, answer_materials = _answer(row)
target_key = row.get("target_cas") or row.get("target")
if not (lookup.get(str(target_key)) or lookup.get(normalize_name(str(target_key)))):
excluded.append({"pair_id": row["pair_id"], "reason": "target_missing_profile"})
continue
resolved_answers = []
missing_answers = []
for item in answer_materials:
keys = [item.get("cas"), item.get("name")]
profile = next(
(lookup.get(str(key)) or lookup.get(normalize_name(str(key))) for key in keys if key),
None,
)
if profile is None:
missing_answers.append(item)
else:
resolved_answers.append(profile)
insufficient = not resolved_answers if shape == "any_of" else bool(missing_answers)
if insufficient:
excluded.append(
{
"pair_id": row["pair_id"],
"reason": "answer_material_missing_profile",
"missing_answer_materials": missing_answers,
}
)
continue
row_mode = mode or row.get("mode")
if row_mode not in MODE_WEIGHTS:
raise ValueError(f"unsupported retrieval mode for {row['pair_id']}: {row_mode!r}")
target_profile = lookup.get(str(target_key)) or lookup.get(normalize_name(str(target_key)))
target_profile_key = profile_key(target_profile)
answer_keys = {profile_key(profile) for profile in resolved_answers}
if target_profile_key in answer_keys:
excluded.append({"pair_id": row["pair_id"], "reason": "answer_resolves_to_target"})
continue
if not mode_supported(row_mode, target_profile, profiles):
excluded.append({"pair_id": row["pair_id"], "reason": "mode_features_unsupported", "mode": row_mode})
continue
mode_counts[row_mode] += 1
if ranking_strategy == "model":
ranked = substitute(str(target_key), profiles, mode=row_mode, top_k=top_k)
else:
candidates = [profile for profile in profiles if profile_key(profile) != target_profile_key]
candidates.sort(
key=lambda profile: hashlib.sha256(
f"{row['pair_id']}|{profile_key(profile)}".encode("utf-8")
).digest()
)
ranked = [
{"candidate": profile.get("name"), "candidate_cas": profile.get("cas")}
for profile in candidates[:top_k]
]
ranked_keys = {
profile_key(profile)
for result in ranked
for profile in [
lookup.get(str(result.get("candidate_cas")))
or lookup.get(normalize_name(result.get("candidate")))
]
if profile
}
matched_keys = ranked_keys & answer_keys
hit = answer_keys <= ranked_keys if shape == "accord" else bool(matched_keys)
substitute_profile = resolved_answers[0]
# Evidence floor: count mutually-observed scoring components between the
# target and the documented answer. Rows under the floor are reported as
# low-evidence so their rank is not read as a trustworthy model outcome.
answer_scored = score_candidate(target_profile, substitute_profile, mode=row_mode) if target_profile is not None else {}
answer_observed_components = len(answer_scored.get("observed_components") or [])
low_evidence = answer_observed_components < MIN_OBSERVED_COMPONENTS
evaluated.append(
{
"pair_id": row["pair_id"],
"grade": row["grade"],
"mode": row_mode,
"answer_shape": shape,
"hit_top_k": bool(hit),
"top_k": ranked,
"documented_answer": row["answer"],
"answer_components_retrieved": len(matched_keys),
"answer_components_total": len(answer_keys),
"answer_observed_components": answer_observed_components,
"answer_evidence_coverage": answer_scored.get("evidence_coverage"),
"low_evidence": low_evidence,
"unavailable_optional_answers": missing_answers if shape == "any_of" else [],
"substantivity_delta_abs": (
abs(
float(target_profile.get("substantivity_log10_predicted"))
- float(substitute_profile.get("substantivity_log10_predicted"))
)
if target_profile
and substitute_profile
and target_profile.get("substantivity_log10_predicted") is not None
and substitute_profile.get("substantivity_log10_predicted") is not None
else None
),
}
)
n = len(evaluated)
n_hit = sum(1 for row in evaluated if row["hit_top_k"])
n_missed = n - n_hit
by_mode_top_k_hit = {}
for row_mode in sorted(mode_counts):
mode_rows = [row for row in evaluated if row["mode"] == row_mode]
by_mode_top_k_hit[row_mode] = wilson_interval(
sum(1 for row in mode_rows if row["hit_top_k"]),
len(mode_rows),
)
subst_deltas = [row["substantivity_delta_abs"] for row in evaluated if row["substantivity_delta_abs"] is not None]
n_low_evidence = sum(1 for row in evaluated if row.get("low_evidence"))
# Trustworthy subset: rows meeting the evidence floor. Hits are also reported
# restricted to this subset so the headline is not carried by prior-only ranks.
trustworthy = [row for row in evaluated if not row.get("low_evidence")]
n_trustworthy = len(trustworthy)
n_trustworthy_hit = sum(1 for row in trustworthy if row["hit_top_k"])
return {
"pimt_version": PIMT_V11,
"mode": mode or "per-row",
"mode_counts": dict(sorted(mode_counts.items())),
"ranking_strategy": ranking_strategy,
"top_k": top_k,
"n_eval_rows": len(eval_rows),
"n_evaluable": n,
"n_excluded": len(excluded),
"n_hit": n_hit,
"n_missed": n_missed,
"n_low_evidence": n_low_evidence,
"n_trustworthy": n_trustworthy,
"n_trustworthy_hit": n_trustworthy_hit,
"top_k_hit": wilson_interval(n_hit, n),
"top_k_hit_trustworthy_only": wilson_interval(n_trustworthy_hit, n_trustworthy) if n_trustworthy else None,
"by_mode_top_k_hit": by_mode_top_k_hit,
"mean_substantivity_delta_abs": float(np.mean(subst_deltas)) if subst_deltas else None,
"evaluated": evaluated,
"excluded": excluded,
}