feat(fase1): spike C - blocking hybrid validator (IR/solver/monte-carlo) [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 10:28:19 +02:00
commit 85439646ec
3 changed files with 359 additions and 0 deletions

View file

@ -0,0 +1,48 @@
# Spike C findings — blocking deterministic hybrid validator (B1)
**Assumption:** a blocking deterministic hybrid-validator can *structurally* block an
out-of-range proposal from ever reaching the expert — and a valid proposal yields a
risk distribution (P10/P50/P90) with a capped self-repair loop.
## Result — CONFIRMED (fully deterministic, no MAF, no endpoint)
Three stages over a typed Pydantic IR (`SavingsProposal`):
1. **IR schema gate.** Field validators + a cross-field `@model_validator` reject
malformed proposals at construction: negative quantities, and a claimed saving that
exceeds the affected items' own total (both proven to raise `ValidationError`).
2. **PuLP solver-in-the-loop.** A real **CBC** solve (PuLP's bundled binary, found at
`solverdir/cbc/osx/i64/cbc` on this Intel mac) bounds the maximum feasible saving at
`MAX_SAVING_FRACTION = 0.30` of the affected items' cost. CBC absence would
**escalate** (`CbcUnavailable`) — there is no silent LP-relaxation fallback (R2).
*(PuLP 3.x deprecates `PULP_CBC_CMD`; PuLP 4.0 will require `pip install pulp[cbc]` +
`COIN_CMD`. The spike silences the cosmetic warning — a Fase 2 migration note.)*
3. **Monte Carlo.** Seeded stdlib `random` + `statistics.quantiles` over uncertain
unit-costs give **P10 ≤ P50 ≤ P90** of the feasible saving, and are reproducible under
the fixed seed.
**Structural block (the B1 headline):** an out-of-range proposal returns a `Rejection`,
a *different type* from `ValidatedProposal` that carries **no percentiles** — accessing
`.p50` on it raises `AttributeError`, so a rejected proposal can never be consumed as
validated. A valid proposal (claim well under the feasible cap) returns a
`ValidatedProposal` with the percentiles. **`self_repair` is capped** at `max_attempts`
and hard-stops (verified it calls the generator exactly N times and no more, B4).
### Worked example (project FV42-GSV-E1, codes 05.2 + 03.1)
- Affected items' total ≈ **1 482 500 NOK**; 30% feasible cap ≈ **444 750 NOK**.
- Claim **200 000**`ValidatedProposal` with ordered P10/P50/P90 around the cap.
- Claim **800 000** → constructs (≤ total) but exceeds P90 feasible → **`Rejection`**.
## Token use
**0 — validator is fully deterministic; live LLM generation is gated.** The
`generate_via_llm` entry point `skip`s without a LOCAL endpoint; all validator / solver /
Monte-Carlo logic is exercised on crafted IR directly, with **zero** model tokens spent.
## Implication for Fase 2
The obligatory, blocking validator is realizable exactly as specified: typed IR +
real solver + risk percentiles + a type-level rejection that cannot leak downstream.
Fase 2 should keep the `Rejection`/`ValidatedProposal` type split (structural block, not
an advisory flag) and the CBC-absent escalate contract.