portfolio-optimiser/spikes/d_verdictstore.py

227 lines
7 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.

"""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
]
)