portfolio-optimiser/docs/fase1-spikes/findings-c.md

2.8 KiB

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 000ValidatedProposal 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 skips 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.