# Polymorphic Decision Protocol — FULL RESEARCH CORPUS Abbreviation: pdp Version: v1.3.0 | Generated: unspecified | Files: 26 | Lines: 5,055 | Payload: 263.0 KiB Source path: /Users/zachshallbetter/Projects/_protocols/polymorphic-decision-protocol Bundle name: llm-full-docs-pdp Corpus Payload SHA-256: 5466fdd641fa4ca2730152941cfd47ef05c70775b5c42299ec44879c211738b4 TABLE OF CONTENTS: - .agents/AGENTS.md (124 lines, 8.7 KiB) - AGENTS.md (9 lines, 0.6 KiB) - Cargo.toml (5 lines, 0.1 KiB) - Dockerfile (40 lines, 1.0 KiB) - README.md (44 lines, 5.1 KiB) - crates/pdp-core/Cargo.toml (9 lines, 0.2 KiB) - crates/pdp-core/src/bin/pdp-cli.rs (115 lines, 5.0 KiB) - crates/pdp-core/src/cents.rs (79 lines, 1.7 KiB) - crates/pdp-core/src/lib.rs (191 lines, 4.5 KiB) - crates/pdp-core/src/math.rs (376 lines, 11.3 KiB) - crates/pdp-core/tests/math_tests.rs (405 lines, 13.0 KiB) - docs/pdp-architecture.md (534 lines, 39.1 KiB) - docs/pdp-essay-draft.md (139 lines, 28.3 KiB) - docs/pdp-fixtures-capture.md (91 lines, 4.8 KiB) - docs/pdp-protocol-article.md (71 lines, 17.6 KiB) - docs/pdp-publication-brief.md (38 lines, 5.8 KiB) - docs/pdp-schema.sql (295 lines, 14.0 KiB) - docs/pdp-test-002-preregistration.md (33 lines, 4.8 KiB) - docs/polymorphic-decision-protocol.md (494 lines, 37.2 KiB) - package.json (15 lines, 0.4 KiB) - scripts/gen-llms.py (182 lines, 6.7 KiB) - tsconfig.json (14 lines, 0.3 KiB) - ui/index.html (938 lines, 32.7 KiB) - ui/pdp-core.test.ts (312 lines, 7.7 KiB) - ui/pdp-core.ts (496 lines, 12.2 KiB) - vercel.json (6 lines, 0.2 KiB) ======================================================================== ======================================================================== ## FILE: .agents/AGENTS.md (124 lines, 8.7 KiB) ======================================================================== # Workspace Agent Rules: Polymorphic Decision Protocol (PDP) These rules guide research, specification design, software engineering, database modeling, and eval pre-registration within the **Polymorphic Decision Protocol (PDP)** repository. --- ## 1. Guiding Principle The core boundary of this protocol is invariant: > **The systems supply evidence and argument. The human owns the weights, the stakes, and the exit. Everything in this package is enforcement of that boundary.** Do not evaluate PDP merely as a post-hoc case study or an LLM consensus aggregator. Evaluate whether it succeeds in establishing a rigorous, falsifiable, and auditable procedure for high-uncertainty decision-making across heterogeneous AI panels. All editorial and implementation decisions must reinforce that objective. --- ## 2. Architectural Layers The repository corpus is framed around three distinct layers: 1. **Layer 1 — The Protocol Specification (`docs/polymorphic-decision-protocol.md`):** The normative specification (v1.x). Defines the four core phases plus portfolio governance: panel formation with two-axis consensus discounting (ACS), ordered pressure testing with pressure-response classification, single-window commitment with pre-committed invalidation triggers, and outcome-blind audit with a persistent calibration ledger. 2. **Layer 2 — The Reference Implementation Architecture (`docs/pdp-architecture.md`, `docs/pdp-schema.sql`):** The software and database substrate. Rust workspace (`crates/`), Postgres system of record (`pdp-schema.sql`), Web Components UI (`ui/`), multi-provider panel engine with evidence-class tool allowlists, cross-vendor flip classification, hash-chained event log, and read-only market integration design. 3. **Layer 3 — Empirical Evals & Preregistration (`docs/pdp-test-002-preregistration.md`, `docs/pdp-fixtures-capture.md`):** Prospective empirical trials, capture rules, eval fixtures (`pdp-evals/fixtures/`), pressure transcripts, counterfactual tracking, and Brier score calibration accounting. --- ## 3. The Epistemological Questions Every component of PDP answers a necessary epistemological question to ensure decision making under uncertainty remains scientifically meaningful and auditable: * **Panel Formation & Discounting:** *How is consensus discounted by evidence-class provenance and cross-model vendor dependence?* * **Adversarial Probing:** *How is model confidence tested under controlled pressure without human bias or self-grading?* * **Weight Custody:** *How does frozen weight custody prevent post-hoc rationalization while guaranteeing a single human exit window?* * **Observation & Evidence:** *What constitutes an empirical flip, and what boundary separates system argument from human decision ownership?* * **Calibration & Auditability:** *What can be measured and compared over time across settlement horizons?* --- ## 4. Editorial Guidelines & Status Honesty ### 4.1 Strict Four-Tier Claim Taxonomy Always enforce strict status honesty across all documentation, commits, and PRs: $$\text{Documented} \neq \text{Implemented} \neq \text{Tested} \neq \text{Empirically Validated}$$ * **Documented:** Features, protocols, or schemas written in markdown specs or RFCs. * **Implemented:** Code or DDL schemas written and integrated into the repository. * **Tested:** Units, fixtures, or integration tests passing in CI/eval suites. * **Empirically Validated:** Preregistered real-world trials with recorded settlements and Brier score audits. When in doubt, state the weaker claim. Never describe a feature as "empirically validated" based solely on an $n=1$ retrospective case study. ### 4.2 Professional Scientific Language * Avoid hype, marketing fluff, or uncritical AI hype. * Prefer precise protocol terminology: *ACS consensus discounting*, *pressure-response classification*, *single-window commitment*, *frozen weight custody*, *Brier score calibration*. --- ## 5. Research Program & Scientific Boundaries ### 5.1 Mission & Central Question * **Mission:** Establish whether multi-model decision processes under high uncertainty can be governed by pre-registered, falsifiable rules that resist model sycophancy and enforce human exit ownership. * **Central Question:** Can AI panel recommendations be systematically discounted by input provenance and tested under adversarial pressure to produce provably superior calibrated decisions compared to naive single-model or un-discounted consensus? ### 5.2 Negative Space — What PDP Is Not * PDP is **not**: * An automated trading bot or order-execution engine (Kalshi integration is strictly read-only by design; order execution has no code path). * A generic agent orchestration framework or workflow builder. * An LLM voting or un-weighted polling mechanism. * A collection of prompt engineering heuristics or conversational UI wrappers. --- ## 6. Repository Rules & Infrastructure ### 6.1 Authority Hierarchy When documentation, code, generated artifacts, or AI outputs conflict, resolve in strict canonical order: $$\text{Protocol Specification} \to \text{Architecture Design} \to \text{Database Schema (DDL)} \to \text{Rust/UI Code} \to \text{Generated Artifacts}$$ Code or generated artifacts **never** silently alter protocol specifications or database immutability constraints. ### 6.2 Database Schema Discipline * Weight custody rows are strictly **frozen upon creation**: Postgres DDL must enforce `INSERT`-only access with no `UPDATE` grant on weight custody tables. * Append-only event logs, pressure test records, and fill records must be secured via database permissions and hash chains. ### 6.3 Agent-First Publishing Architecture (`llms.txt`) * Maintain programmatically generated `llms.txt` and `llms-full.txt` single-file context corpora using `python3 scripts/gen-llms.py`. * Run `python3 scripts/gen-llms.py` whenever documentation, specifications, or schemas are updated. --- ## 7. Quality Gates & Publication Rules * **Preregistration Mandatory:** Any new decision evaluation trial must be pre-registered (stating panel, sequence, capture rules, classification scheme, counterfactuals, and failure conditions) *before* execution. * **Outcome-Blind Audit:** Post-settlement calibration audits must evaluate predictions against pre-registered counterfactuals without retroactive modification of weights or hypotheses. * **Negative Results Permanent:** Disproven hypotheses or failed panel configurations must be recorded permanently in evals and calibration ledgers. --- ## 8. Agent Execution Methodology & Forward-Thinking Completion Engine To ensure all research, coding, documentation, and refactoring tasks are executed with maximum intelligence and completed to end-to-end working state, agents must strictly follow these 5 core execution rules: ### 8.1 Ambient Adaptation & Engineering Judgment * **Style & Convention Matching**: Read surrounding files before editing. Match comment density, naming conventions, architectural idioms, and structural patterns. * **Root-Cause Resolution**: Inspect actual log/test outputs and trace upstream data flow. Address underlying logic errors rather than applying superficial symptom patches or suppressing errors. ### 8.2 Autonomous Execution to Completion * **End-to-End Ownership**: Do not halt halfway or stop at draft code/plans. Drive tasks through research, implementation, build validation, and verification until the requested outcome is fully realized. * **Self-Correction & Autonomous Momentum**: If a chosen path hits a blocker, evaluate alternative approaches immediately. Log deviations in `scratch/implementation-notes.md` and proceed with the best alternative rather than halting execution to ask trivial questions. ### 8.3 Unknowns Discovery Framework (Blind Spot Pass) Map tasks across the 4 Context Quadrants before and during execution: 1. **Known Knowns**: Explicit user requirements in the prompt. 2. **Known Unknowns**: Identified gaps requiring investigation before coding. 3. **Unknown Knowns**: Implicit expectations and visual/architectural standards. 4. **Unknown Unknowns**: Unconsidered architectural traps, security boundaries, or edge cases. * **Pre-Implementation Blind Spot Pass**: Scan the codebase for hidden dependencies and domain risks before proposing solutions. ### 8.4 Live Implementation Ledger (`scratch/implementation-notes.md`) For non-trivial tasks, maintain a scratch ledger at `/brain//scratch/implementation-notes.md` recording decisions, rationale, deviations, and verification proof. ### 8.5 Empirical Runtime Verification * **No Premature Success Claims**: Never declare completion based solely on un-verified code edits. * **Validation Suite**: Always run empirical verification (e.g. `python3 scripts/gen-llms.py`, `cargo test`, `npm test`, DDL validation) before finishing a task. ======================================================================== ## FILE: AGENTS.md (9 lines, 0.6 KiB) ======================================================================== # Agent Guide: Polymorphic Decision Protocol (PDP) This repository keeps workspace agent rules and research doctrine in `.agents/AGENTS.md`. | File | Covers | |---|---| | **[`.agents/AGENTS.md`](.agents/AGENTS.md)** | **Protocol doctrine & engineering rules** — the guiding principle, architectural layers, epistemological questions, editorial guidelines, status honesty rules, research program boundaries, and quality gates | Read [`.agents/AGENTS.md`](.agents/AGENTS.md) before writing code, modifying database schemas, editing specifications, or adding eval fixtures. ======================================================================== ## FILE: Cargo.toml (5 lines, 0.1 KiB) ======================================================================== [workspace] resolver = "2" members = [ "crates/pdp-core", ] ======================================================================== ## FILE: Dockerfile (40 lines, 1.0 KiB) ======================================================================== # Stage 1: Build environment FROM rust:1.80-slim-bookworm AS builder # Install build dependencies RUN apt-get update && apt-get install -y \ pkg-config \ libssl-dev \ git \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy the entire workspace configuration and source COPY Cargo.toml Cargo.lock ./ COPY crates/pdp-core/Cargo.toml crates/pdp-core/Cargo.toml COPY crates/pdp-core/src crates/pdp-core/src # Compile release build for the workspace RUN cargo build --release --workspace # Stage 2: Slim runtime container FROM debian:bookworm-slim # Install runtime SSL dependencies RUN apt-get update && apt-get install -y \ ca-certificates \ libssl3 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy compiled binaries from builder stage # (Ready to copy other crates like pdp-server as they are implemented) COPY --from=builder /app/target/release/ /app/bin/ ENV PORT=8080 EXPOSE 8080 # Keep container running or execute pdp-server when implemented CMD ["/app/bin/pdp-core"] ======================================================================== ## FILE: README.md (44 lines, 5.1 KiB) ======================================================================== # Polymorphic Decision Protocol (PDP) **Protocol version:** `1.3.0` **Status:** Reference design and recorded case A procedure for making high-uncertainty decisions with panels of AI systems, where consensus is discounted by input provenance, model confidence is tested under controlled pressure, and the human owns the weights, the stakes, and the exit. The protocol came out of a settled real-money case: a 2026 World Cup thesis formed across Claude, Gemini, and ChatGPT, staked as an eight-market ladder on Kalshi ($10 → $58.86), and held through a documented event in which one panel system abandoned the correct pick under nothing but expressed displeasure, retracted when challenged, four days before the market it had switched to settled at zero. The protocol formalizes what held, closes what broke, and pre-registers the next test. ## The one-sentence version The systems supply evidence and argument. The human owns the weights, the stakes, and the exit. Everything in this package is enforcement of that boundary. ## Package contents | File | What it is | Read it if | |---|---|---| | `polymorphic-decision-protocol.md` | **The specification (v1.3).** Four phases plus portfolio governance: panel formation with two-axis consensus discounting (ACS), ordered stress testing with pressure-response classification, single-window commitment with pre-committed invalidation, outcome-blind audit with a persistent calibration ledger. Formulas, decision matrix, failure taxonomy, prompt library, and the full case woven through every section. | You want the method | | `pdp-architecture.md` | **The reference implementation design (v0.3).** Rust workspace (axum, sqlx, tokio), Postgres system of record, Web Components UI, multi-provider panel engine with evidence-class tool allowlists, cross-vendor flip classification, hash-chained event log, failover matrix, degraded-mode ladder, eval framework, cost model. Kalshi integration is read-only by design; order execution has no code path. | You want to build it | | `pdp-schema.sql` | **The database schema.** Postgres DDL matching the architecture: pipeline state, weight custody (one frozen row per decision, no UPDATE grant), pressure tests with no-self-grading vendor constraint, portfolio correlation groups, append-only ledger/events/fills enforced by grants, hash chain, costs. | You want the data model | | `pdp-essay-draft.md` | **The case narrative.** "The Model Was Right Until I Disagreed." Long-form account of the panel, the thesis, the ladder, the July 10 reversal and retraction, and the three conditions that made the capture possible. Includes the evidence note and references. | You want the story | | `pdp-protocol-article.md` | **The methodology article.** The protocol explained on its own terms, phase by phase, with the case as compressed reference. Companion to the essay. | You want the argument | | `pdp-test-002-preregistration.md` | **The pre-registration.** Panel, sequence, capture rules, classification scheme, counterfactual, and audit for the next protocol-governed decision, fixed before the decision exists, with failure conditions named in advance and a publish-regardless commitment. | You want the falsifiability | | `pdp-fixtures-capture.md` | **The eval fixture record.** Fixture 001 (the documented Claude flip, human-verified) plus the LOST markers for the Gemini/ChatGPT pressure transcripts and the prospective capture protocol that replaces retrospective recovery. Seed data for `pdp-evals/fixtures/flips/`. | You want the training data | | `pdp-publication-brief.md` | **The publication plan.** Novelty positioning against the located literature, claim-scope decisions, venue sequencing, risk register. | You want the context | ## Reading order For the method: essay → protocol article → specification. For the build: specification → architecture → schema. The pre-registration stands alone and is the package's strongest validity instrument: it converts a retrospective story into a prospective, falsifiable program. ## Status and honest limits - The documented pressure-driven flip is one system, one instance (n=1). The cross-vendor pressure comparison is designed but not yet run under capture discipline; Test 002 runs it. - The performance claim (protocol picks beat naive picks) is a hypothesis with a recording apparatus, not a result. Every decision records its naive counterfactual at thesis time; Brier scores accumulate across settlements. - The case win is, by the protocol's own required accounting, 80% variance. - Individual components have research neighbors (see references in the essay). The assembly is, as far as located, novel. Corrections welcome. ## Terminology "Polymorphic" here means the panel construction: one question, asked in many forms, across systems and across evidence classes. It is unrelated to the object-oriented-programming and malware senses of the word. ## License and citation Specification and articles © 2026 Zach Shallbetter. A DOI for the specification (v1.3) is planned via the author's deposit pipeline; until minted, cite the specification by title, version, and https://zachshallbetter.com. ORCID: 0009-0009-9450-3429. ======================================================================== ## FILE: crates/pdp-core/Cargo.toml (9 lines, 0.2 KiB) ======================================================================== [package] name = "pdp-core" version = "0.1.0" edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" ======================================================================== ## FILE: crates/pdp-core/src/bin/pdp-cli.rs (115 lines, 5.0 KiB) ======================================================================== use std::env; use std::fs; use std::io::{self, Read}; use pdp_core::{ math::{acs, score_model, flip_distance, committed_fraction, allocate_ladder, matrix_cell}, AgentVerdict, ScoreDimension, LegSpec, cents::Cents, SizingBand }; fn read_input(arg: Option) -> io::Result { match arg { Some(ref file_path) if file_path != "-" => fs::read_to_string(file_path), _ => { let mut buffer = String::new(); io::stdin().read_to_string(&mut buffer)?; Ok(buffer) } } } fn main() { let args: Vec = env::args().collect(); if args.len() < 2 { print_usage(); std::process::exit(1); } let command = args[1].as_str(); match command { "acs" => { let input = read_input(args.get(2).cloned()).expect("Failed to read input"); let verdicts: Vec = serde_json::from_str(&input).expect("Invalid AgentVerdict JSON array"); let result = acs(&verdicts); println!("{}", serde_json::to_string_pretty(&result).unwrap()); } "fd" => { let input = read_input(args.get(2).cloned()).expect("Failed to read input"); let dimensions: Vec = serde_json::from_str(&input).expect("Invalid ScoreDimension JSON array"); let scored = score_model(&dimensions); if let Some(fd) = flip_distance(&scored) { println!( "{{\n \"distance\": {:.6},\n \"sensitive_dimension\": {}\n}}", fd.distance, fd.sensitive_dimension.map_or("null".to_string(), |s| format!("\"{}\"", s)) ); } else { println!("{{\n \"distance\": null,\n \"sensitive_dimension\": null\n}}"); } } "sizing" => { if args.len() < 8 { eprintln!("Usage: pdp-cli sizing

"); std::process::exit(1); } let p: f64 = args[2].parse().expect("Invalid p float"); let b: f64 = args[3].parse().expect("Invalid b float"); let k: f64 = args[4].parse().expect("Invalid k float"); let band: SizingBand = match args[5].as_str() { "Full" | "full" => SizingBand::Full, "Half" | "half" => SizingBand::Half, "Quarter" | "quarter" => SizingBand::Quarter, "Kill" | "kill" => SizingBand::Kill, _ => panic!("Invalid SizingBand. Options: Full, Half, Quarter, Kill"), }; let ruin_cap = Cents::from_dollars(args[6].parse().expect("Invalid ruin_cap float")); let bankroll = Cents::from_dollars(args[7].parse().expect("Invalid bankroll float")); let size = committed_fraction(p, b, k, band, ruin_cap, bankroll); println!("{{\n \"committed_cents\": {},\n \"committed_dollars\": {:.2}\n}}", size.0, size.to_dollars()); } "matrix" => { if args.len() < 4 { eprintln!("Usage: pdp-cli matrix "); std::process::exit(1); } let acs_score: f64 = args[2].parse().expect("Invalid acs_score float"); let fd_val: f64 = args[3].parse().expect("Invalid flip_distance float"); let band = matrix_cell(acs_score, fd_val); println!("{{\n \"sizing_band\": \"{:?}\"\n}}", band); } "allocate" => { if args.len() < 3 { eprintln!("Usage: pdp-cli allocate [legs_json_file]"); std::process::exit(1); } let total = Cents::from_dollars(args[2].parse().expect("Invalid total_dollars float")); let input = read_input(args.get(3).cloned()).expect("Failed to read input"); let legs: Vec = serde_json::from_str(&input).expect("Invalid LegSpec JSON array"); match allocate_ladder(total, &legs) { Ok(ladder) => println!("{}", serde_json::to_string_pretty(&ladder).unwrap()), Err(err) => { eprintln!("Allocation Error: {:?}", err); std::process::exit(1); } } } _ => { eprintln!("Unknown command: {}", command); print_usage(); std::process::exit(1); } } } fn print_usage() { println!( "PDP CLI Utility\n\n\ Usage:\n \ pdp-cli acs [verdicts_json_file] - Computes Consensus Consensus Score (ACS)\n \ pdp-cli fd [dimensions_json_file] - Computes Scored Model and Flip Distance\n \ pdp-cli matrix - Looks up the decision SizingBand\n \ pdp-cli sizing

- Computes committed fraction\n \ pdp-cli allocate [legs_json] - Performs integer-cent rounding ladder allocation\n\n\ Use '-' or omit the JSON file argument to read from stdin." ); } ======================================================================== ## FILE: crates/pdp-core/src/cents.rs (79 lines, 1.7 KiB) ======================================================================== use serde::{Serialize, Deserialize}; use std::ops::{Add, Sub, AddAssign, SubAssign, Mul, Div}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct Cents(pub u64); impl Cents { pub const ZERO: Self = Cents(0); pub fn from_dollars(dollars: f64) -> Self { Cents((dollars * 100.0).round() as u64) } pub fn to_dollars(&self) -> f64 { self.0 as f64 / 100.0 } } impl Default for Cents { fn default() -> Self { Cents::ZERO } } impl std::fmt::Display for Cents { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "${:.2}", self.to_dollars()) } } impl Add for Cents { type Output = Self; fn add(self, other: Self) -> Self { Cents(self.0 + other.0) } } impl Sub for Cents { type Output = Self; fn sub(self, other: Self) -> Self { Cents(self.0.saturating_sub(other.0)) } } impl AddAssign for Cents { fn add_assign(&mut self, other: Self) { self.0 += other.0; } } impl SubAssign for Cents { fn sub_assign(&mut self, other: Self) { self.0 = self.0.saturating_sub(other.0); } } // Support multiplying by standard float factors (e.g. fraction) impl Mul for Cents { type Output = Self; fn mul(self, factor: f64) -> Self { if factor <= 0.0 { Cents::ZERO } else { Cents((self.0 as f64 * factor).round() as u64) } } } impl Div for Cents { type Output = Self; fn div(self, divisor: f64) -> Self { if divisor <= 0.0 { Cents::ZERO } else { Cents((self.0 as f64 / divisor).round() as u64) } } } ======================================================================== ## FILE: crates/pdp-core/src/lib.rs (191 lines, 4.5 KiB) ======================================================================== pub mod cents; pub mod math; use serde::{Serialize, Deserialize}; use std::collections::HashMap; use cents::Cents; use sha2::{Sha256, Digest}; pub fn calculate_hash(value: &T) -> Result { let serialized = serde_json::to_string(value)?; let mut hasher = Sha256::new(); hasher.update(serialized.as_bytes()); let hash_result = hasher.finalize(); Ok(format!("{:x}", hash_result)) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Vendor { Anthropic, OpenAI, Google, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EvidenceClass { Quant, Market, Structural, Contrarian, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AgentVerdict { pub agent_id: String, pub vendor: Vendor, pub evidence_class: EvidenceClass, pub pick: String, pub stated_p: f64, pub alternatives: Vec<(String, f64)>, pub load_bearing_facts: Vec, pub searched: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Acs { pub score: f64, pub agreeing_agents: Vec, pub total_agents: usize, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FlipDistance { pub distance: f64, pub sensitive_dimension: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ScoreDimension { pub name: String, pub weight: f64, pub scores: HashMap, // option_name -> score (0.0 to 10.0) pub justification: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ScoredModel { pub dimensions: Vec, pub option_scores: HashMap, // option_name -> overall weighted score } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SizingBand { Full, Half, Quarter, Kill, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LegType { Terminal, Checkpoint, SingleEvent, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LegSpec { pub name: String, pub leg_type: LegType, pub proportion: f64, pub allocated_amount: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Ladder { pub total: Cents, pub legs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct InvalidationCondition { pub condition_text: String, pub observable_inputs: Vec, pub exit_action: String, pub status: String, // "Armed", "Triggered", "Expired" } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct KellyInputs { pub p: f64, pub b: f64, pub k: f64, pub bankroll: Cents, pub ruin_cap: Cents, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CommitmentStructure { pub ladder: Ladder, pub total_committed: Cents, pub matrix_cell: SizingBand, pub kelly_inputs: KellyInputs, pub confirmed_by: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PreCommittedExit { pub action_text: String, pub target_asset: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Outcome { pub winning_pick: String, pub details: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ProcessScore { pub panel_decorrelation: u8, pub elicitation_quality: u8, pub self_critique_before_pressure: u8, pub weights_frozen: u8, pub sensitivity_analysis: u8, pub invalidation_quality: u8, pub entry_discipline: u8, pub lock_integrity: u8, pub total_score: u8, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Gate { G1, G2, G3, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum DecisionState { Draft, Eliciting, G1Pending { acs: Acs, }, StressTesting, G2Pending { flip_distance: FlipDistance, invalidation: InvalidationCondition, }, ComposingStructure, G3Pending { proposal: CommitmentStructure, }, Locked { structure: CommitmentStructure, invalidation: InvalidationCondition, }, InvalidationTriggered { exit: PreCommittedExit, }, Settled { outcome: Outcome, }, Audited { process_score: ProcessScore, }, Killed { at_gate: Gate, reason: String, }, } ======================================================================== ## FILE: crates/pdp-core/src/math.rs (376 lines, 11.3 KiB) ======================================================================== use crate::{Acs, AgentVerdict, FlipDistance, ScoredModel, SizingBand, LegSpec, LegType, Ladder, cents::Cents}; use serde::{Serialize, Deserialize}; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum AllocError { SumMismatch { expected: f64, actual: f64 }, TerminalBoundViolated { sum: f64, min: f64, max: f64 }, CheckpointBoundViolated { sum: f64, min: f64, max: f64 }, SingleEventBoundViolated { name: String, amount: f64, max: f64 }, } impl std::fmt::Display for AllocError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AllocError::SumMismatch { expected, actual } => { write!(f, "Allocated sum mismatch: expected {}, got {}", expected, actual) } AllocError::TerminalBoundViolated { sum, min, max } => { write!(f, "Terminal allocation bound violated: sum {}, must be between {} and {}", sum, min, max) } AllocError::CheckpointBoundViolated { sum, min, max } => { write!(f, "Checkpoint allocation bound violated: sum {}, must be between {} and {}", sum, min, max) } AllocError::SingleEventBoundViolated { name, amount, max } => { write!(f, "Single-event leg '{}' allocation ({}) exceeds max allowed ({})", name, amount, max) } } } } impl std::error::Error for AllocError {} /// Calculates the Adjusted Convergence Score (ACS) for a panel of agents. /// Under PDP v1.3: /// ACS = Σ_i (agreement_i * decorrelation_weight_i) / N /// where N is the total number of agents in the panel. pub fn acs(agents: &[AgentVerdict]) -> Acs { if agents.is_empty() { return Acs { score: 0.0, agreeing_agents: Vec::new(), total_agents: 0, }; } // 1. Count picks to find the modal pick let mut pick_counts = HashMap::new(); for agent in agents { *pick_counts.entry(&agent.pick).or_insert(0) += 1; } let mut modal_pick = String::new(); let mut max_count = 0; for (pick, count) in pick_counts { if count > max_count { max_count = count; modal_pick = pick.clone(); } } // 2. Identify agreeing agents let agreeing_agents: Vec<&AgentVerdict> = agents .iter() .filter(|a| a.pick == modal_pick) .collect(); let agreeing_ids: Vec = agreeing_agents.iter().map(|a| a.agent_id.clone()).collect(); // 3. Compute weight for each agreeing agent let mut numerator = 0.0; for agent in &agreeing_agents { let mut shares_both = false; let mut shares_one = false; for other in &agreeing_agents { if agent.agent_id == other.agent_id { continue; } let same_vendor = agent.vendor == other.vendor; let same_evidence = agent.evidence_class == other.evidence_class; if same_vendor && same_evidence { shares_both = true; } else if same_vendor || same_evidence { shares_one = true; } } let weight = if shares_both { 0.2 } else if shares_one { 0.5 } else { 1.0 }; numerator += weight; } let score = numerator / agents.len() as f64; Acs { score, agreeing_agents: agreeing_ids, total_agents: agents.len(), } } /// Computes the scored model from dimensions. pub fn score_model(dimensions: &[crate::ScoreDimension]) -> ScoredModel { let mut option_scores = HashMap::new(); let mut options = std::collections::HashSet::new(); for dim in dimensions { for option in dim.scores.keys() { options.insert(option.clone()); } } for option in options { let mut total_score = 0.0; for dim in dimensions { let score = dim.scores.get(&option).cloned().unwrap_or(0.0); total_score += dim.weight * score; } option_scores.insert(option, total_score); } ScoredModel { dimensions: dimensions.to_vec(), option_scores, } } /// Calculates the Flip Distance of a ScoredModel. /// Flip Distance = min single-weight perturbation (redistributed pro-rata) reversing the top-2 ranking. pub fn flip_distance(model: &ScoredModel) -> Option { if model.option_scores.len() < 2 { return None; } // Sort options to find top-2 let mut sorted_options: Vec<(&String, &f64)> = model.option_scores.iter().collect(); sorted_options.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal)); let top_option = sorted_options[0].0; let second_option = sorted_options[1].0; let score_a = *sorted_options[0].1; let score_b = *sorted_options[1].1; let d_overall = score_a - score_b; if d_overall <= 0.0 { // Already flipped or tied return Some(FlipDistance { distance: 0.0, sensitive_dimension: None, }); } let mut min_abs_delta = f64::INFINITY; let mut best_dim = None; for dim in &model.dimensions { let w_k = dim.weight; if w_k >= 1.0 { continue; } let s_a = dim.scores.get(top_option).cloned().unwrap_or(0.0); let s_b = dim.scores.get(second_option).cloned().unwrap_or(0.0); let d_k = s_a - s_b; // C = (d_k - d_overall) / (1.0 - w_k) let c = (d_k - d_overall) / (1.0 - w_k); if c > 0.0 { // we need delta <= -d_overall / c let required_delta = -d_overall / c; if required_delta >= -w_k { let abs_delta = required_delta.abs(); if abs_delta < min_abs_delta { min_abs_delta = abs_delta; best_dim = Some(dim.name.clone()); } } } else if c < 0.0 { // we need delta >= -d_overall / c let required_delta = -d_overall / c; if required_delta <= 1.0 - w_k { let abs_delta = required_delta.abs(); if abs_delta < min_abs_delta { min_abs_delta = abs_delta; best_dim = Some(dim.name.clone()); } } } } if min_abs_delta.is_infinite() { None } else { Some(FlipDistance { distance: min_abs_delta, sensitive_dimension: best_dim, }) } } /// Matches the ACS and Flip Distance values against the decision matrix to return the SizingBand. pub fn matrix_cell(acs_val: f64, fd_val: f64) -> SizingBand { if fd_val > 0.15 { // Robust if acs_val >= 0.75 { SizingBand::Full } else if acs_val >= 0.40 { SizingBand::Half } else { SizingBand::Quarter } } else if fd_val >= 0.05 { // Sensitive if acs_val >= 0.75 { SizingBand::Half } else if acs_val >= 0.40 { SizingBand::Quarter } else { SizingBand::Kill } } else { // Fragile if acs_val >= 0.75 { SizingBand::Quarter } else { SizingBand::Kill } } } /// Computes the committed fraction (monetary amount) based on Fractional Kelly with ruin cap. pub fn committed_fraction( p: f64, b: f64, k: f64, m: SizingBand, ruin_cap: Cents, bankroll: Cents, ) -> Cents { if b <= 0.0 { return Cents::ZERO; } let q = 1.0 - p; let f_star = (p * b - q) / b; if f_star <= 0.0 { return Cents::ZERO; } let m_multiplier = match m { SizingBand::Full => 1.0, SizingBand::Half => 0.5, SizingBand::Quarter => 0.25, SizingBand::Kill => 0.0, }; let fraction = f_star * k * m_multiplier; let proposed_amount = bankroll * fraction; if proposed_amount > ruin_cap { ruin_cap } else { proposed_amount } } /// Allocates the total commitment amount to ladder legs using integer-cent rounding. /// Validates constraints: /// - Terminal legs sum is 40%-60% of total /// - Checkpoint legs sum is 40%-60% of total /// - Single-event legs are each <= 10% of total pub fn allocate_ladder(total: Cents, legs: &[LegSpec]) -> Result { if legs.is_empty() { if total == Cents::ZERO { return Ok(Ladder { total, legs: Vec::new() }); } else { return Err(AllocError::SumMismatch { expected: total.to_dollars(), actual: 0.0 }); } } let total_cents = total.0; let mut allocated_cents = Vec::new(); let mut sum_cents = 0; for leg in legs { let cents = (total_cents as f64 * leg.proportion).round() as u64; allocated_cents.push(cents); sum_cents += cents; } let remainder = total_cents as i64 - sum_cents as i64; if remainder != 0 { let step = remainder.signum(); let mut rem_to_distribute = remainder.abs(); let mut idx = 0; while rem_to_distribute > 0 { if step > 0 { allocated_cents[idx % legs.len()] += 1; } else { allocated_cents[idx % legs.len()] = allocated_cents[idx % legs.len()].saturating_sub(1); } rem_to_distribute -= 1; idx += 1; } } let mut final_legs = Vec::new(); let mut terminal_sum = Cents::ZERO; let mut checkpoint_sum = Cents::ZERO; for (i, leg) in legs.iter().enumerate() { let amount = Cents(allocated_cents[i]); match leg.leg_type { LegType::Terminal => terminal_sum = terminal_sum + amount, LegType::Checkpoint => checkpoint_sum = checkpoint_sum + amount, LegType::SingleEvent => { let max_allowed = total * 0.10; if amount > max_allowed { return Err(AllocError::SingleEventBoundViolated { name: leg.name.clone(), amount: amount.to_dollars(), max: max_allowed.to_dollars(), }); } } } let mut final_leg = leg.clone(); final_leg.allocated_amount = Some(amount); final_legs.push(final_leg); } // Sum verification let mut actual_sum = Cents::ZERO; for leg in &final_legs { actual_sum = actual_sum + leg.allocated_amount.unwrap_or(Cents::ZERO); } if actual_sum != total { return Err(AllocError::SumMismatch { expected: total.to_dollars(), actual: actual_sum.to_dollars() }); } let min_terminal = total * 0.40; let max_terminal = total * 0.60; if terminal_sum < min_terminal || terminal_sum > max_terminal { return Err(AllocError::TerminalBoundViolated { sum: terminal_sum.to_dollars(), min: min_terminal.to_dollars(), max: max_terminal.to_dollars(), }); } let min_checkpoint = total * 0.40; let max_checkpoint = total * 0.60; if checkpoint_sum < min_checkpoint || checkpoint_sum > max_checkpoint { return Err(AllocError::CheckpointBoundViolated { sum: checkpoint_sum.to_dollars(), min: min_checkpoint.to_dollars(), max: max_checkpoint.to_dollars(), }); } Ok(Ladder { total, legs: final_legs, }) } ======================================================================== ## FILE: crates/pdp-core/tests/math_tests.rs (405 lines, 13.0 KiB) ======================================================================== use pdp_core::{ math::{acs, committed_fraction, flip_distance, matrix_cell, score_model, allocate_ladder, AllocError}, AgentVerdict, EvidenceClass, LegSpec, LegType, ScoreDimension, SizingBand, Vendor, cents::Cents, }; use std::collections::HashMap; #[test] fn test_acs_unanimous_cross_vendor_same_evidence() { // 3 agents, different vendors, same evidence class let agents = vec![ AgentVerdict { agent_id: "claude-1".to_string(), vendor: Vendor::Anthropic, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, AgentVerdict { agent_id: "gemini-1".to_string(), vendor: Vendor::Google, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, AgentVerdict { agent_id: "gpt-1".to_string(), vendor: Vendor::OpenAI, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, ]; let result = acs(&agents); assert!((result.score - 0.5).abs() < 1e-9); // (0.5 + 0.5 + 0.5) / 3 = 0.5 assert_eq!(result.agreeing_agents.len(), 3); } #[test] fn test_acs_unanimous_cross_vendor_cross_evidence() { // 3 agents, different vendors, different evidence classes let agents = vec![ AgentVerdict { agent_id: "claude-1".to_string(), vendor: Vendor::Anthropic, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, AgentVerdict { agent_id: "gemini-1".to_string(), vendor: Vendor::Google, evidence_class: EvidenceClass::Market, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, AgentVerdict { agent_id: "gpt-1".to_string(), vendor: Vendor::OpenAI, evidence_class: EvidenceClass::Structural, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, ]; let result = acs(&agents); assert!((result.score - 1.0).abs() < 1e-9); // (1.0 + 1.0 + 1.0) / 3 = 1.0 } #[test] fn test_acs_one_analyst_n_hats() { // 3 agents, same vendor, same evidence class let agents = vec![ AgentVerdict { agent_id: "gpt-1".to_string(), vendor: Vendor::OpenAI, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, AgentVerdict { agent_id: "gpt-2".to_string(), vendor: Vendor::OpenAI, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, AgentVerdict { agent_id: "gpt-3".to_string(), vendor: Vendor::OpenAI, evidence_class: EvidenceClass::Quant, pick: "Spain".to_string(), stated_p: 0.20, alternatives: vec![], load_bearing_facts: vec![], searched: "".to_string(), }, ]; let result = acs(&agents); assert!((result.score - 0.2).abs() < 1e-9); // (0.2 + 0.2 + 0.2) / 3 = 0.2 } #[test] fn test_flip_distance_spain_france() { // Spain/France World Cup case let mut scores1 = HashMap::new(); scores1.insert("Spain".to_string(), 8.0); scores1.insert("France".to_string(), 10.0); let mut scores2 = HashMap::new(); scores2.insert("Spain".to_string(), 9.0); scores2.insert("France".to_string(), 8.0); let mut scores3 = HashMap::new(); scores3.insert("Spain".to_string(), 10.0); scores3.insert("France".to_string(), 7.0); let mut scores4 = HashMap::new(); scores4.insert("Spain".to_string(), 9.0); scores4.insert("France".to_string(), 8.0); let mut scores5 = HashMap::new(); scores5.insert("Spain".to_string(), 5.0); scores5.insert("France".to_string(), 8.0); let mut scores6 = HashMap::new(); scores6.insert("Spain".to_string(), 10.0); scores6.insert("France".to_string(), 8.0); let mut scores7 = HashMap::new(); scores7.insert("Spain".to_string(), 9.0); scores7.insert("France".to_string(), 6.0); let mut scores8 = HashMap::new(); scores8.insert("Spain".to_string(), 7.0); scores8.insert("France".to_string(), 9.0); let dimensions = vec![ ScoreDimension { name: "Attack Peak".to_string(), weight: 0.15, scores: scores1, justification: "".to_string(), }, ScoreDimension { name: "Attack Depth".to_string(), weight: 0.15, scores: scores2, justification: "".to_string(), }, ScoreDimension { name: "Defense".to_string(), weight: 0.20, scores: scores3, justification: "".to_string(), }, ScoreDimension { name: "Recent Form".to_string(), weight: 0.15, scores: scores4, justification: "".to_string(), }, ScoreDimension { name: "WC Pedigree".to_string(), weight: 0.10, scores: scores5, justification: "".to_string(), }, ScoreDimension { name: "Cohesion".to_string(), weight: 0.10, scores: scores6, justification: "".to_string(), }, ScoreDimension { name: "Injury Risk".to_string(), weight: 0.10, scores: scores7, justification: "".to_string(), }, ScoreDimension { name: "Experience".to_string(), weight: 0.05, scores: scores8, justification: "".to_string(), }, ]; let model = score_model(&dimensions); // Spain overall: 8.65, France overall: 7.95 assert!((model.option_scores.get("Spain").unwrap() - 8.65).abs() < 1e-9); assert!((model.option_scores.get("France").unwrap() - 7.95).abs() < 1e-9); let fd = flip_distance(&model).unwrap(); // Minimum delta should be in "WC Pedigree", which is ~0.17027 assert_eq!(fd.sensitive_dimension.as_deref(), Some("WC Pedigree")); assert!((fd.distance - 0.17027027).abs() < 1e-6); } #[test] fn test_matrix_cell_lookup() { // Robust, ACS strong assert_eq!(matrix_cell(0.80, 0.20), SizingBand::Full); // Robust, ACS single-axis assert_eq!(matrix_cell(0.50, 0.20), SizingBand::Half); // Robust, ACS weak assert_eq!(matrix_cell(0.30, 0.20), SizingBand::Quarter); // Sensitive, ACS strong assert_eq!(matrix_cell(0.80, 0.10), SizingBand::Half); // Sensitive, ACS weak assert_eq!(matrix_cell(0.30, 0.10), SizingBand::Kill); // Fragile, ACS strong assert_eq!(matrix_cell(0.80, 0.02), SizingBand::Quarter); // Fragile, ACS weak assert_eq!(matrix_cell(0.30, 0.02), SizingBand::Kill); } #[test] fn test_kelly_sizing_cap() { let p = 0.20; let b = 4.9; // 5.9x blended return means net odds = 4.9 let k = 0.25; // Quarter Kelly let m = SizingBand::Quarter; // Quarter multiplier (0.25) let ruin_cap = Cents::from_dollars(5.0); // limit commitment to $5 let bankroll = Cents::from_dollars(1000.0); // f* = (0.20 * 4.9 - 0.8) / 4.9 = (0.98 - 0.8) / 4.9 = 0.18 / 4.9 = 0.03673 // sizing_fraction = 0.03673 * 0.25 * 0.25 = 0.0022959 // proposed_amount = 0.0022959 * 1000 = 2.2959 // Since proposed_amount < ruin_cap, it should return ~2.2959 let amount = committed_fraction(p, b, k, m, ruin_cap, bankroll); assert_eq!(amount, Cents(230)); // 229.59 cents rounded to 230 cents ($2.30) // If we decrease ruin_cap to 1.0, it should be clamped to 1.0 let amount_capped = committed_fraction(p, b, k, m, Cents::from_dollars(1.0), bankroll); assert_eq!(amount_capped, Cents::from_dollars(1.0)); } #[test] fn test_allocate_ladder_success() { let total = Cents::from_dollars(10.00); let legs = vec![ LegSpec { name: "Spain Winner".to_string(), leg_type: LegType::Terminal, proportion: 0.50, allocated_amount: None, }, LegSpec { name: "Spain Semis".to_string(), leg_type: LegType::Checkpoint, proportion: 0.40, allocated_amount: None, }, LegSpec { name: "ESP vs CPV".to_string(), leg_type: LegType::SingleEvent, proportion: 0.10, allocated_amount: None, }, ]; let ladder = allocate_ladder(total, &legs).unwrap(); assert_eq!(ladder.total, Cents::from_dollars(10.00)); assert_eq!(ladder.legs[0].allocated_amount, Some(Cents::from_dollars(5.00))); assert_eq!(ladder.legs[1].allocated_amount, Some(Cents::from_dollars(4.00))); assert_eq!(ladder.legs[2].allocated_amount, Some(Cents::from_dollars(1.00))); } #[test] fn test_allocate_ladder_rounding() { let total = Cents::from_dollars(10.00); // Let's create proportions that require rounding distribution let legs = vec![ LegSpec { name: "Spain Winner".to_string(), leg_type: LegType::Terminal, proportion: 0.501, allocated_amount: None, }, LegSpec { name: "Spain Semis".to_string(), leg_type: LegType::Checkpoint, proportion: 0.401, allocated_amount: None, }, LegSpec { name: "ESP vs CPV".to_string(), leg_type: LegType::SingleEvent, proportion: 0.098, allocated_amount: None, }, ]; let ladder = allocate_ladder(total, &legs).unwrap(); let mut sum = Cents::ZERO; for leg in &ladder.legs { sum = sum + leg.allocated_amount.unwrap(); } assert_eq!(sum, Cents::from_dollars(10.00)); } #[test] fn test_allocate_ladder_errors() { let total = Cents::from_dollars(10.00); // 1. Single-event leg > 10% of total let legs1 = vec![ LegSpec { name: "Spain Winner".to_string(), leg_type: LegType::Terminal, proportion: 0.45, allocated_amount: None, }, LegSpec { name: "Spain Semis".to_string(), leg_type: LegType::Checkpoint, proportion: 0.40, allocated_amount: None, }, LegSpec { name: "ESP vs CPV".to_string(), leg_type: LegType::SingleEvent, proportion: 0.15, // 15% > 10% allocated_amount: None, }, ]; let err1 = allocate_ladder(total, &legs1).unwrap_err(); assert!(matches!(err1, AllocError::SingleEventBoundViolated { .. })); // 2. Terminal sum < 40% of total let legs2 = vec![ LegSpec { name: "Spain Winner".to_string(), leg_type: LegType::Terminal, proportion: 0.35, // 35% < 40% allocated_amount: None, }, LegSpec { name: "Spain Semis".to_string(), leg_type: LegType::Checkpoint, proportion: 0.55, allocated_amount: None, }, LegSpec { name: "ESP vs CPV".to_string(), leg_type: LegType::SingleEvent, proportion: 0.10, allocated_amount: None, }, ]; let err2 = allocate_ladder(total, &legs2).unwrap_err(); assert!(matches!(err2, AllocError::TerminalBoundViolated { .. })); } #[test] fn test_content_hashing() { use pdp_core::{calculate_hash, InvalidationCondition}; let condition1 = InvalidationCondition { condition_text: "Yamal AND Nico Williams both miss game time".to_string(), observable_inputs: vec!["yamal_injury".to_string(), "williams_injury".to_string()], exit_action: "France becomes pick".to_string(), status: "Armed".to_string(), }; let condition2 = condition1.clone(); let hash1 = calculate_hash(&condition1).unwrap(); let hash2 = calculate_hash(&condition2).unwrap(); assert_eq!(hash1, hash2); let mut condition3 = condition1.clone(); condition3.status = "Triggered".to_string(); let hash3 = calculate_hash(&condition3).unwrap(); assert_ne!(hash1, hash3); } ======================================================================== ## FILE: docs/pdp-architecture.md (534 lines, 39.1 KiB) ======================================================================== # PDP Engine — Architecture v0.3 Rust service implementing the Polymorphic Decision Protocol (PDP v1.2) as an automated pipeline with hard human gates. Postgres as the system of record. Web Components UI. Multi-provider agent panel (Anthropic, OpenAI, Google). Kalshi read-only market integration; order execution deliberately out of scope for v1. v0.2 adds: failure-domain map and failover matrix (§11), degraded-mode ladder (§12), evaluation framework (§13), testing strategy (§14), operational runbook triggers (§15). v0.3 adds: cost model and budget enforcement (§16); portfolio governance support for PDP v1.3 §4A (decision_groups, shared-observable dedupe) noted for the schema in a future revision. --- ## 1. System overview ``` ┌────────────────────────────────────────────────────────────────┐ │ pdp-web (UI) │ │ Web Components, no framework · SSE for live pipeline state │ └──────────────────────────┬─────────────────────────────────────┘ │ HTTP/JSON + SSE ┌──────────────────────────▼─────────────────────────────────────┐ │ pdp-server (Rust) │ │ axum HTTP API · pipeline state machine · gate enforcement │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ │ │ panel │ │ stress │ │ commit │ │ monitor │ │ │ │ engine │ │ engine │ │ composer │ │ daemon (tokio) │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────────┬──────────┘ │ └───────┼────────────┼────────────┼─────────────────┼────────────┘ │ │ │ │ ┌───────▼────────────▼───┐ ┌─────▼─────┐ ┌────────▼──────────┐ │ provider adapters │ │ Kalshi │ │ evidence APIs │ │ anthropic │ openai │ │ │ (read) │ │ search / odds │ │ google │ └───────────┘ └───────────────────┘ └────────────────────────┘ │ ┌───────▼────────────────────────────────────────────────────────┐ │ Postgres (system of record) │ │ decisions · agents · elicitations · models · pressure_tests │ │ invalidations · structures · ledger · audits · calibration │ │ append-only event log with hash chain │ └────────────────────────────────────────────────────────────────┘ ``` Single deployable binary (`pdp-server`) embedding the static UI assets. The monitor daemon runs as a tokio task inside the same process; no separate workers in v1. --- ## 2. Crate layout (Cargo workspace) ``` pdp/ ├── crates/ │ ├── pdp-core # domain types, state machine, protocol math (no I/O) │ ├── pdp-providers # LLM provider adapters behind one trait │ ├── pdp-evidence # Kalshi client, search adapters, odds normalization │ ├── pdp-store # sqlx Postgres layer, event log, migrations │ ├── pdp-judge # elicitation validator, flip classifier, audit scorer │ └── pdp-server # axum API, SSE, gate endpoints, embedded UI, monitor ├── ui/ # Web Components source (esbuild → embedded assets) └── migrations/ ``` **Dependency rule:** `pdp-core` has zero I/O dependencies — protocol math (ACS, Flip Distance, Kelly, matrix) is pure functions over domain types, unit-testable without network or DB. Everything else depends on core; core depends on nothing internal. --- ## 3. pdp-core — domain model and protocol math ### 3.1 Pipeline state machine One decision is a state machine; transitions are the protocol's gates. Illegal transitions are unrepresentable — gate checks live in the transition functions, not in handlers. ```rust pub enum DecisionState { Draft, Eliciting, // panel calls in flight G1Pending { acs: Acs }, // human reviews convergence StressTesting, // self-critique → model → sensitivity → pressure G2Pending { // human reviews thesis + invalidation flip_distance: FlipDistance, invalidation: InvalidationCondition, }, ComposingStructure, // matrix cell + sizing + ladder proposal G3Pending { proposal: CommitmentStructure }, // human confirms entries Locked { // frozen; monitor-only structure: CommitmentStructure, invalidation: InvalidationCondition, }, InvalidationTriggered { exit: PreCommittedExit }, Settled { outcome: Outcome }, Audited { process_score: ProcessScore }, Killed { at_gate: Gate, reason: String }, } ``` Key invariants enforced by construction: - `Locked` exposes no method that mutates `structure`. The only transitions out are `InvalidationTriggered` (world-state event from the monitor) and `Settled`. - Weights are set in a `FrozenWeights` type created only via `Weights::freeze(human_actor_id)` — provider adapters cannot construct it. §3.3 weight custody is a type-system property. - `PressureFlip` classification must be recorded before `G2Pending` can transition forward. ### 3.2 Protocol math (pure) ```rust // Two-axis ACS (PDP §2.3) pub fn acs(agents: &[AgentVerdict]) -> Acs; // weight per agreeing agent: 1.0 both axes independent, 0.5 one, 0.2 none // Flip Distance (PDP §3.4): min single-weight perturbation reversing top-2 pub fn flip_distance(model: &ScoredModel) -> FlipDistance; // Decision matrix (PDP §4.1) pub fn matrix_cell(acs: Acs, fd: FlipDistance) -> SizingBand; // Full|Half|Quarter|Kill // Fractional Kelly with ruin cap (PDP §4.2) pub fn committed_fraction(p: Prob, b: NetOdds, k: KellyMult, m: SizingBand, ruin_cap: Money, bankroll: Money) -> Money; // Ladder allocation (PDP §4.3): terminal 40–60, checkpoints 40–60, events ≤10 pub fn allocate_ladder(total: Money, legs: &[LegSpec]) -> Result; ``` All functions total over their input types; allocation violations (single-event legs over 10%) are `AllocError`, not clamps — the composer surfaces them to the human rather than silently fixing them. ### 3.3 Hash identity Every persisted protocol artifact (elicitation, frozen weights, scored model, invalidation condition, commitment structure) carries a content hash computed by a single canonical serializer: explicit field order, no map iteration, no `Timestamp::now()` inside hashed content. Hashes chain in the event log (§6.3) so the decision record is tamper-evident — the audit reads a decision whose history provably wasn't rewritten after settlement. --- ## 4. pdp-providers — the panel engine ### 4.1 Provider trait ```rust #[async_trait] pub trait Provider: Send + Sync { fn vendor(&self) -> Vendor; // Anthropic | OpenAI | Google async fn elicit(&self, req: ElicitationRequest) -> Result; fn capabilities(&self) -> Capabilities; // tool use, search, max context } ``` Adapters: `anthropic` (Messages API), `openai` (Responses API), `google` (Gemini API). Each maps one canonical `ElicitationRequest` (system prompt from the PDP §7 library + evidence-class restriction + required-fields schema) to the vendor's wire format and back to one canonical `RawResponse`. Vendor quirks stay inside the adapter; core never sees them. ### 4.2 Evidence-class enforcement Restriction is enforced by tool allowlist, not by prompt hope: | Agent role | Tools granted | |---|---| | Quant | none (or a stats-dataset tool if configured) | | Market | `kalshi_prices`, `odds_lookup` only | | Structural | `web_search` with betting/odds domains excluded | | Contrarian | full toolset + adversarial mandate prompt | Tool calls are executed server-side by `pdp-evidence`; the provider adapters expose them as vendor-native tool/function definitions. An agent physically cannot fetch outside its class. Every tool invocation is logged per agent — this is the raw data the ACS axis-2 weight is computed from, replacing self-declared evidence sources with observed ones. ### 4.3 Panel fan-out Panel run = `N vendors × M roles` elicitations, launched concurrently (`tokio::JoinSet`), each in an isolated context (no shared conversation state — decorrelation by construction). Per-call: timeout, bounded retries on transient errors (typed `Retryable` vs `Fatal`), response validated by `pdp-judge` against the 5 required fields. A hedge triggers exactly one structured re-prompt (F2); second failure marks the agent `Dropped { decision_id }` and the panel proceeds if ≥3 agents remain, else the run fails to `G1Pending` with a degraded-panel flag. Cost note: full 3×4 panel ≈ 12 elicitations + tool calls; config supports per-decision panel shape (`panel = "3x4" | "3x1" | "1x4"`) so cheap decisions can run cheap panels. --- ## 5. pdp-judge — validation, classification, audit Three judge functions, each a single LLM call with structured output, each running on a **different vendor than the agent being judged** (a Claude flip is classified by GPT or Gemini — no self-grading): 1. **Elicitation validator** — checks the 5 required fields, extracts pick/probability/load-bearing facts into typed columns 2. **Flip classifier** — input: pre-pressure response, post-pressure response, the §3.5 classification table. Output: `Robust | EvidenceFlip { new_evidence } | PressureFlip { reweighted_dims } | Collapse`. For `EvidenceFlip`, the claimed new evidence is checked against the agent's tool-call log — evidence the agent never actually retrieved reclassifies the flip as `PressureFlip` (the case's Williams-datum-as-cover pattern, §3.5, detected mechanically) 3. **Audit scorer** — outcome-redacted decision log in, 8-criterion process score out (PDP §5.3) Judge outputs are schema-validated (`serde` + JSON schema in the request); malformed judge output is a hard error and re-run, never a silent default — no-fallback discipline on the critical path. --- ## 6. pdp-store — Postgres schema ### 6.1 Core tables ```sql decisions (id, question, domain, state, created_by, created_at) agents (id, decision_id, vendor, role, model_string, status) elicitations (id, agent_id, round, raw_response jsonb, pick, stated_p, alternatives jsonb, load_bearing_facts jsonb, tool_calls jsonb, content_hash, created_at) frozen_weights (id, decision_id, weights jsonb, justifications jsonb, frozen_by, frozen_at, content_hash) scored_models (id, decision_id, weights_id, scores jsonb, flip_distance numeric, content_hash) pressure_tests (id, agent_id, pre_elicitation_id, post_response jsonb, classification, classified_by_vendor, evidence_check jsonb) invalidations (id, decision_id, condition_text, observable_inputs jsonb, exit_action, status) -- armed | triggered | expired structures (id, decision_id, legs jsonb, total_committed numeric, matrix_cell, kelly_inputs jsonb, confirmed_by, frozen_at, content_hash) fills (id, structure_id, leg_ref, external_id, price, qty, reconciled boolean, discrepancy text) ledger (id, decision_id, event_at, checkpoint_ref, expected, actual, thesis_impact) -- none|supports|damages|invalidates audits (id, decision_id, criterion_scores jsonb, total smallint, outcome_redacted boolean, scored_by_vendor) calibration (id, agent_ref, vendor, evidence_class, pick, stated_p, outcome, pressure_response, surfaced_invalidation boolean, facts_verified boolean, decision_id) ``` Operationally queried fields are typed columns; `jsonb` is reserved for snapshots and raw responses (audit blobs), versioned via `content_hash`. ### 6.2 Constraints enforcing the protocol ```sql -- one frozen weight set per decision, ever CREATE UNIQUE INDEX one_weights_per_decision ON frozen_weights (decision_id); -- structures immutable after freeze: no UPDATE grants; corrections are new -- rows with superseded_by, and only before state = 'locked' -- ledger append-only: no UPDATE/DELETE grants to the app role -- state transitions validated by trigger against the legal-transition table -- (belt-and-suspenders under the type-level state machine) ``` Idempotency: pipeline steps (panel fan-out, judge calls, monitor polls) are keyed by `(decision_id, step, round)` with `ON CONFLICT DO NOTHING` on their result rows — a crashed/retried step never double-writes. ### 6.3 Event log ```sql events (seq bigserial, decision_id, event_type, payload jsonb, payload_hash, prev_hash, created_at) ``` Every state transition and artifact creation appends an event; `prev_hash` chains per decision. The audit and calibration views are derived from typed tables, but the chain is the proof the record wasn't retro-edited — directly serving invariant 7 (outcome-blind audit) against the subtlest failure mode: quietly revising the thesis after settlement. --- ## 7. pdp-server — API and gates ### 7.1 Endpoints ``` POST /decisions create draft POST /decisions/:id/elicit launch panel (async; SSE progress) GET /decisions/:id/events SSE stream: pipeline state + artifacts POST /decisions/:id/gates/g1 human verdict: proceed | kill PUT /decisions/:id/weights human sets + freezes weights (only writer) POST /decisions/:id/stress run self-critique → score → sensitivity → pressure POST /decisions/:id/gates/g2 proceed | kill (requires invalidation on file) POST /decisions/:id/compose matrix + sizing + ladder proposal POST /decisions/:id/gates/g3 confirm structure → Locked POST /decisions/:id/fills record external fills; reconcile (F6) POST /decisions/:id/settle record outcome → trigger audit GET /calibration cross-decision agent/vendor calibration ``` Gate endpoints require an authenticated human actor (single-user v1: static token + actor id; deny-by-default middleware on every route). No endpoint mutates a `Locked` structure; `POST /fills` records external reality, it does not place orders. ### 7.2 Monitor daemon Tokio task, per `Locked` decision: - Polls Kalshi settlement status for each leg (bounded interval + jitter; timeouts and size caps on all external I/O) - Polls the invalidation condition's observable inputs (configured per decision: e.g. a squad-news search query whose judge-parsed result maps to condition variables) - Writes `ledger` rows; on `thesis_impact = invalidates`, transitions the decision to `InvalidationTriggered` and alerts (webhook/ntfy) — it never executes an exit; the pre-committed exit is presented for one-click human confirmation - F10 detection: ≥3 `damages` rows without invalidation → flags the invalidation design for audit ### 7.3 Provider/config hygiene API keys env-only, validated at boot alongside all config (fail fast, explicit defaults); structured logs (`tracing`) carry `decision_id` + `content_hash` on every span; prompts and raw responses logged truncated with full versions only in the DB; metrics (Prometheus) cover per-vendor error rates, latency, retries, judge disagreement rate, and gate dwell times. --- ## 8. ui/ — Web Components No framework. Native custom elements + `