feat(fase1): spike D - verdictstore + expel retrieval [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 10:32:39 +02:00
commit f7a36b59ac
3 changed files with 302 additions and 0 deletions

145
spikes/d_verdictstore.py Normal file
View file

@ -0,0 +1,145 @@
"""Spike D — VerdictStore + ExpeL retrieval (B2).
De-risks the assumption that retrieval surfaces a relevant *prior* verdict for a similar
new proposal the substrate for the framework's learning loop.
**Similarity is structural, not textual (reviewer refinement #2):** a weighted score over
*structured* fields Jaccard on the affected cost-code set + a `measure_type` match + a
magnitude-bucket match on the claimed saving **never** raw description text. This makes
the retrieval non-tautological: a true match with different wording beats decoys that
merely share surface text.
`retrieve()` is the guaranteed SC-D deliverable (always tested). A thin
`ExpeLContextProvider` wraps it for few-shot injection via the real `ContextProvider`
hook. The embedding-based similarity path is intentionally **out of scope** for a
throwaway spike (a Fase 2 option see findings-d).
"""
from __future__ import annotations
from dataclasses import dataclass
from agent_framework import ContextProvider
# Weights: the affected cost-code overlap dominates, then measure type, then magnitude.
_W_CODES, _W_MEASURE, _W_MAGNITUDE = 0.60, 0.25, 0.15
_MAGNITUDE_BUCKETS = [(0.0, 1e5), (1e5, 5e5), (5e5, 1e6), (1e6, float("inf"))]
@dataclass(frozen=True)
class ProposalFeatures:
"""The *structured* features retrieval ranks on. ``description`` is surface text and
is deliberately NOT part of the similarity score."""
affected_codes: frozenset[str]
measure_type: str
claimed_saving_nok: float
description: str = ""
@dataclass(frozen=True)
class Verdict:
"""One historical expert verdict in the store."""
id: str
proposal_features: ProposalFeatures
decision: str # "approved" | "rejected"
rationale: str
def _magnitude_bucket(value: float) -> int:
for i, (low, high) in enumerate(_MAGNITUDE_BUCKETS):
if low <= value < high:
return i
return len(_MAGNITUDE_BUCKETS) - 1
def _jaccard(a: frozenset[str], b: frozenset[str]) -> float:
union = a | b
return len(a & b) / len(union) if union else 1.0
def similarity(query: ProposalFeatures, candidate: ProposalFeatures) -> float:
"""Weighted structural similarity in [0, 1] — text is ignored by design."""
codes = _jaccard(query.affected_codes, candidate.affected_codes)
measure = 1.0 if query.measure_type == candidate.measure_type else 0.0
magnitude = 1.0 if _magnitude_bucket(query.claimed_saving_nok) == _magnitude_bucket(
candidate.claimed_saving_nok
) else 0.0
return _W_CODES * codes + _W_MEASURE * measure + _W_MAGNITUDE * magnitude
@dataclass
class VerdictStore:
"""Minimal in-memory store of historical verdicts."""
verdicts: list[Verdict]
def retrieve(self, query: ProposalFeatures, k: int) -> list[Verdict]:
"""Return the top-``k`` verdicts by structural similarity. Deterministic: ties
break by verdict id, so ordering is stable across runs."""
if k <= 0:
raise ValueError(f"k must be positive, got {k}")
ranked = sorted(
self.verdicts,
key=lambda v: (-similarity(query, v.proposal_features), v.id),
)
return ranked[:k]
class ExpeLContextProvider(ContextProvider):
"""Wraps `VerdictStore.retrieve` for ExpeL few-shot injection.
Asserted only against the introspected `ContextProvider` interface
(`before_run` + `SessionContext.extend_instructions`); the `retrieve` ranking remains
the guaranteed SC-D deliverable regardless of the MAF session surface.
"""
def __init__(self, store: VerdictStore, query: ProposalFeatures, *, k: int = 3) -> None:
super().__init__(source_id="expel-verdictstore")
self._store = store
self._query = query
self._k = k
def format_fewshot(self) -> str:
hits = self._store.retrieve(self._query, self._k)
body = "\n".join(f"- [{v.id}] {v.decision}: {v.rationale}" for v in hits)
return f"Relevant prior verdicts (ExpeL few-shot):\n{body}"
async def before_run(self, *, agent: object, session: object, context: object, state: dict) -> None:
context.extend_instructions([self.format_fewshot()]) # type: ignore[attr-defined]
def seed_store() -> VerdictStore:
"""Seed 12 synthetic verdicts spanning the reference domain's cost codes, measure
types, magnitudes, and decisions (B2 store of 1020)."""
rows = [
("V01", {"05.2", "03.1"}, "scope_reduction", 180_000, "approved", "asphalt + base course trimmed within feasible range"),
("V02", {"05.2"}, "rate_renegotiation", 60_000, "approved", "renegotiated asphalt unit rate"),
("V03", {"07.4"}, "material_substitution", 120_000, "rejected", "granite kerb substitution unsafe"),
("V04", {"09.1"}, "scope_reduction", 240_000, "approved", "fewer LED masts on low-traffic stretch"),
("V05", {"02.3", "03.1"}, "scope_reduction", 350_000, "rejected", "soil replacement is load-bearing, cannot cut"),
("V06", {"21.2"}, "rate_renegotiation", 90_000, "approved", "blasting rate renegotiated"),
("V07", {"22.4"}, "material_substitution", 700_000, "rejected", "fiber shotcrete spec is mandated"),
("V08", {"88.2"}, "scope_reduction", 150_000, "approved", "concrete repair area re-measured smaller"),
("V09", {"87.3"}, "material_substitution", 130_000, "approved", "alternative membrane qualified"),
("V10", {"05.2", "03.1"}, "rate_renegotiation", 200_000, "approved", "combined paving rate discount"),
("V11", {"01.1"}, "scope_reduction", 95_000, "rejected", "rigging is fixed cost, no scope to cut"),
("V12", {"31.3"}, "scope_reduction", 110_000, "approved", "drainage length reduced after survey"),
]
return VerdictStore(
verdicts=[
Verdict(
id=vid,
proposal_features=ProposalFeatures(
affected_codes=frozenset(codes),
measure_type=mtype,
claimed_saving_nok=saving,
description=desc,
),
decision=decision,
rationale=desc,
)
for vid, codes, mtype, saving, decision, desc in rows
]
)