diff --git a/.github/workflows/sym-arch-002b1-simple-baselines.yml b/.github/workflows/sym-arch-002b1-simple-baselines.yml new file mode 100644 index 000000000..a2bbf9ad6 --- /dev/null +++ b/.github/workflows/sym-arch-002b1-simple-baselines.yml @@ -0,0 +1,74 @@ +name: SYM-ARCH-002B1 Strong Simple Baselines + +on: + pull_request: + branches: + - main + - research/sym-arch-002a3-validity-v1 + paths: + - '.github/workflows/sym-arch-002b1-simple-baselines.yml' + - 'crates/domains/symthaea-psych-bench/src/experiment/baselines.rs' + - 'crates/domains/symthaea-psych-bench/src/experiment/baseline_fairness.rs' + - 'crates/domains/symthaea-psych-bench/src/experiment/baseline_panel.rs' + - 'crates/domains/symthaea-psych-bench/src/lib.rs' + - 'crates/domains/symthaea-psych-bench/Cargo.toml' + - 'docs/research/SYM_ARCH_002B1_STRONG_SIMPLE_BASELINES_V1.md' + - 'Cargo.toml' + - 'Cargo.lock' + workflow_dispatch: + +concurrency: + # Automatic branch/PR validation keeps only the newest run. Manual validation + # is deliberate and receives a unique group through github.run_id. + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || 'auto' }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + strong-simple-baselines: + name: Validate readout-matched analytic baselines + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.96.0 + with: + components: rustfmt + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-sym-arch-002b1-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-sym-arch-002b1- + + - name: Check B1 Rust formatting + run: | + rustfmt --edition 2024 --check \ + crates/domains/symthaea-psych-bench/src/experiment/baselines.rs \ + crates/domains/symthaea-psych-bench/src/experiment/baseline_fairness.rs \ + crates/domains/symthaea-psych-bench/src/experiment/baseline_panel.rs \ + crates/domains/symthaea-psych-bench/src/lib.rs + + - name: Run strong-simple baseline tests + run: | + cargo test -p symthaea-psych-bench --lib experiment_baselines:: -- --nocapture + cargo test -p symthaea-psych-bench --lib experiment_baseline_fairness:: -- --nocapture + cargo test -p symthaea-psych-bench --lib experiment_baseline_panel:: -- --nocapture + + - name: Check psych-bench library + run: | + cargo check -p symthaea-psych-bench --lib diff --git a/crates/domains/symthaea-psych-bench/src/experiment/baseline_fairness.rs b/crates/domains/symthaea-psych-bench/src/experiment/baseline_fairness.rs new file mode 100644 index 000000000..5d231612a --- /dev/null +++ b/crates/domains/symthaea-psych-bench/src/experiment/baseline_fairness.rs @@ -0,0 +1,340 @@ +// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics +// SPDX-License-Identifier: AGPL-3.0-or-later +// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root +//! Claim-boundary audits for SYM-ARCH-002B1 baseline contrasts. +//! +//! Sharing an RLS algorithm is not enough to call two conditions readout-matched. +//! The effective feature dimension determines the number of trainable readout +//! weights and the O(d^2) inverse-covariance state. This module makes that +//! distinction executable so a capacity-changing reference contrast cannot be +//! surfaced as clean representation-level evidence. + +use crate::experiment_baselines::{ + MatchedBaselineFamilySpec, SimpleBaselineKind, SimpleBaselineSpec, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadoutShape { + pub input_dimension: usize, + pub state_dimension: usize, + pub trainable_parameters: usize, + pub covariance_elements: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContrastClaimCeiling { + /// Schema, RLS protocol, effective readout shape, and paired random-seed index + /// are matched. Differences may be attributed at the representation level, + /// but not automatically to equal resource efficiency or a specific algebraic + /// mechanism inside either encoder. + RepresentationLevel, + /// Useful as a benchmark/reference comparison, but capacity or protocol differs + /// enough that a representation-only interpretation is not admissible. + ReferenceOnly, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BaselineContrastAudit { + pub left_kind: SimpleBaselineKind, + pub right_kind: SimpleBaselineKind, + pub left_spec_digest: String, + pub right_spec_digest: String, + pub same_feature_schema: bool, + pub same_rls_contract: bool, + pub same_effective_feature_dimension: bool, + pub same_readout_shape: bool, + /// Applies only when both encoders use representation randomness. One-hot has + /// no representation seed and therefore does not fail this field by design. + pub paired_random_seed_index: bool, + pub left_readout_shape: ReadoutShape, + pub right_readout_shape: ReadoutShape, + pub replay_examples_left: usize, + pub replay_examples_right: usize, + pub temporal_state_bytes_left: usize, + pub temporal_state_bytes_right: usize, + pub claim_ceiling: ContrastClaimCeiling, + pub qualifiers: Vec, +} + +fn effective_feature_dimension(spec: &SimpleBaselineSpec) -> Result { + spec.validate()?; + match spec.kind { + SimpleBaselineKind::OneHotRls => spec.feature_schema.one_hot_dimension(), + SimpleBaselineKind::FixedRandomTanhRls | SimpleBaselineKind::VanillaHdcRls => { + Ok(spec.encoded_dimension) + } + } +} + +fn readout_shape(spec: &SimpleBaselineSpec) -> Result { + let input_dimension = effective_feature_dimension(spec)?; + let state_dimension = input_dimension + .checked_add(usize::from(spec.rls.include_bias)) + .ok_or_else(|| "baseline readout state dimension overflow".to_string())?; + let covariance_elements = state_dimension + .checked_mul(state_dimension) + .ok_or_else(|| "baseline readout covariance shape overflow".to_string())?; + Ok(ReadoutShape { + input_dimension, + state_dimension, + trainable_parameters: state_dimension, + covariance_elements, + }) +} + +fn uses_representation_randomness(kind: SimpleBaselineKind) -> bool { + matches!( + kind, + SimpleBaselineKind::FixedRandomTanhRls | SimpleBaselineKind::VanillaHdcRls + ) +} + +/// Audit whether a pair of simple baselines supports a representation-level +/// contrast under the frozen B1 contract. +/// +/// The audit intentionally does not require equal fixed-encoder storage: encoder +/// resource cost is an outcome of the representation choice and must be reported +/// separately. It does require equal learner-visible schema, RLS settings, and +/// effective readout shape so predictive-capacity differences are not silently +/// attributed to the encoder. +pub fn audit_baseline_contrast( + left: &SimpleBaselineSpec, + right: &SimpleBaselineSpec, +) -> Result { + left.validate()?; + right.validate()?; + if left.kind == right.kind { + return Err("baseline contrast requires two different baseline kinds".into()); + } + + let left_readout_shape = readout_shape(left)?; + let right_readout_shape = readout_shape(right)?; + let same_feature_schema = left.feature_schema == right.feature_schema; + let same_rls_contract = left.rls == right.rls; + let same_effective_feature_dimension = + left_readout_shape.input_dimension == right_readout_shape.input_dimension; + let same_readout_shape = left_readout_shape == right_readout_shape; + let paired_random_seed_index = if uses_representation_randomness(left.kind) + && uses_representation_randomness(right.kind) + { + left.representation_seed == right.representation_seed + } else { + true + }; + + let mut qualifiers = Vec::new(); + if !same_feature_schema { + qualifiers.push("learner-visible categorical schemas differ".into()); + } + if !same_rls_contract { + qualifiers.push("RLS ridge/forgetting/bias contract differs".into()); + } + if !same_effective_feature_dimension { + qualifiers.push(format!( + "effective feature dimensions differ: left={} right={}", + left_readout_shape.input_dimension, right_readout_shape.input_dimension + )); + } + if !same_readout_shape { + qualifiers.push(format!( + "RLS trainable/covariance shape differs: left_state={} left_cov={} right_state={} right_cov={}", + left_readout_shape.state_dimension, + left_readout_shape.covariance_elements, + right_readout_shape.state_dimension, + right_readout_shape.covariance_elements + )); + } + if !paired_random_seed_index { + qualifiers.push("randomized encoders use different representation-seed indices".into()); + } + + let clean_representation_contrast = same_feature_schema + && same_rls_contract + && same_effective_feature_dimension + && same_readout_shape + && paired_random_seed_index; + + Ok(BaselineContrastAudit { + left_kind: left.kind, + right_kind: right.kind, + left_spec_digest: left.digest()?, + right_spec_digest: right.digest()?, + same_feature_schema, + same_rls_contract, + same_effective_feature_dimension, + same_readout_shape, + paired_random_seed_index, + left_readout_shape, + right_readout_shape, + // B1 simple baselines are replay-free and stateless in time by contract. + replay_examples_left: 0, + replay_examples_right: 0, + temporal_state_bytes_left: 0, + temporal_state_bytes_right: 0, + claim_ceiling: if clean_representation_contrast { + ContrastClaimCeiling::RepresentationLevel + } else { + ContrastClaimCeiling::ReferenceOnly + }, + qualifiers, + }) +} + +/// Audit all three pairwise contrasts emitted by one matched B1 family. +pub fn audit_matched_family( + family: &MatchedBaselineFamilySpec, +) -> Result, String> { + let specs = family.specs()?; + debug_assert_eq!(specs.len(), 3); + Ok(vec![ + audit_baseline_contrast(&specs[0], &specs[1])?, + audit_baseline_contrast(&specs[0], &specs[2])?, + audit_baseline_contrast(&specs[1], &specs[2])?, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::experiment_baselines::{ + CategoricalFeatureSchema, CategoricalFeatureSpec, RlsConfig, + SIMPLE_BASELINE_SCHEMA_V1, + }; + + fn schema() -> CategoricalFeatureSchema { + CategoricalFeatureSchema { + features: vec![ + CategoricalFeatureSpec { + name: "a".into(), + values: vec![0, 1], + }, + CategoricalFeatureSpec { + name: "b".into(), + values: vec![0, 1], + }, + ], + } + } + + fn family(encoded_dimension: usize) -> MatchedBaselineFamilySpec { + MatchedBaselineFamilySpec { + feature_schema: schema(), + encoded_dimension, + representation_seed: 17, + rls: RlsConfig { + ridge: 1.0, + forgetting_factor: 1.0, + include_bias: true, + }, + } + } + + #[test] + fn random_hdc_is_representation_level_when_readout_shape_is_matched() { + let audits = audit_matched_family(&family(64)).unwrap(); + let random_hdc = audits + .iter() + .find(|audit| { + audit.left_kind == SimpleBaselineKind::FixedRandomTanhRls + && audit.right_kind == SimpleBaselineKind::VanillaHdcRls + }) + .unwrap(); + assert_eq!( + random_hdc.claim_ceiling, + ContrastClaimCeiling::RepresentationLevel + ); + assert!(random_hdc.same_readout_shape); + assert!(random_hdc.paired_random_seed_index); + assert!(random_hdc.qualifiers.is_empty()); + } + + #[test] + fn one_hot_random_is_reference_only_when_capacity_changes() { + // The categorical source has four one-hot coordinates while the random + // representation has 64, so the RLS trainable/covariance shape differs. + let audits = audit_matched_family(&family(64)).unwrap(); + let one_hot_random = audits + .iter() + .find(|audit| { + audit.left_kind == SimpleBaselineKind::OneHotRls + && audit.right_kind == SimpleBaselineKind::FixedRandomTanhRls + }) + .unwrap(); + assert_eq!( + one_hot_random.claim_ceiling, + ContrastClaimCeiling::ReferenceOnly + ); + assert!(!one_hot_random.same_effective_feature_dimension); + assert!(!one_hot_random.same_readout_shape); + assert!(!one_hot_random.qualifiers.is_empty()); + } + + #[test] + fn one_hot_random_can_be_clean_when_effective_dimensions_match() { + // one-hot dimension is exactly four here. + let audits = audit_matched_family(&family(4)).unwrap(); + let one_hot_random = audits + .iter() + .find(|audit| { + audit.left_kind == SimpleBaselineKind::OneHotRls + && audit.right_kind == SimpleBaselineKind::FixedRandomTanhRls + }) + .unwrap(); + assert_eq!( + one_hot_random.claim_ceiling, + ContrastClaimCeiling::RepresentationLevel + ); + assert!(one_hot_random.same_readout_shape); + } + + #[test] + fn randomized_representation_contrast_requires_paired_seed_index() { + let specs = family(32).specs().unwrap(); + let random = specs + .iter() + .find(|spec| spec.kind == SimpleBaselineKind::FixedRandomTanhRls) + .unwrap(); + let hdc = specs + .iter() + .find(|spec| spec.kind == SimpleBaselineKind::VanillaHdcRls) + .unwrap(); + let mut different_seed = hdc.clone(); + different_seed.representation_seed += 1; + let audit = audit_baseline_contrast(random, &different_seed).unwrap(); + assert_eq!(audit.claim_ceiling, ContrastClaimCeiling::ReferenceOnly); + assert!(!audit.paired_random_seed_index); + } + + #[test] + fn rls_protocol_mismatch_downgrades_the_claim_ceiling() { + let specs = family(32).specs().unwrap(); + let random = specs + .iter() + .find(|spec| spec.kind == SimpleBaselineKind::FixedRandomTanhRls) + .unwrap(); + let hdc = specs + .iter() + .find(|spec| spec.kind == SimpleBaselineKind::VanillaHdcRls) + .unwrap(); + let mut altered = hdc.clone(); + altered.rls.ridge = 2.0; + let audit = audit_baseline_contrast(random, &altered).unwrap(); + assert_eq!(audit.claim_ceiling, ContrastClaimCeiling::ReferenceOnly); + assert!(!audit.same_rls_contract); + } + + #[test] + fn same_kind_is_not_a_scientific_contrast() { + let spec = SimpleBaselineSpec { + schema: SIMPLE_BASELINE_SCHEMA_V1.into(), + kind: SimpleBaselineKind::FixedRandomTanhRls, + feature_schema: schema(), + encoded_dimension: 16, + representation_seed: 5, + rls: RlsConfig::default(), + }; + assert!(audit_baseline_contrast(&spec, &spec).is_err()); + } +} diff --git a/crates/domains/symthaea-psych-bench/src/experiment/baseline_panel.rs b/crates/domains/symthaea-psych-bench/src/experiment/baseline_panel.rs new file mode 100644 index 000000000..b7e79dc43 --- /dev/null +++ b/crates/domains/symthaea-psych-bench/src/experiment/baseline_panel.rs @@ -0,0 +1,414 @@ +// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics +// SPDX-License-Identifier: AGPL-3.0-or-later +// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root +//! Atomic matched-exposure execution for SYM-ARCH-002B1 baselines. +//! +//! A static fairness audit can prove that baseline configurations are comparable, +//! but it cannot prove that a later runner actually showed every model the same +//! examples in the same order. `MatchedBaselinePanel` is the preferred claim- +//! bearing execution path: one call feeds all baselines, updates are fail-atomic, +//! and training/evaluation streams receive deterministic cryptographic digests. + +use crate::experiment_baselines::{ + BaselineResourceFootprint, MatchedBaselineFamilySpec, SimpleBaselineAgent, SimpleBaselineKind, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const MATCHED_BASELINE_PANEL_SCHEMA_V1: &str = "symthaea.matched-baseline-panel/v1"; +const TRAIN_STREAM_DOMAIN: &[u8] = b"symthaea.matched-baseline-panel.train/v1"; +const EVAL_STREAM_DOMAIN: &[u8] = b"symthaea.matched-baseline-panel.eval/v1"; +const PANEL_SNAPSHOT_DOMAIN: &[u8] = b"symthaea.matched-baseline-panel.snapshot/v1"; + +fn initialized_hasher(domain: &[u8]) -> blake3::Hasher { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher +} + +fn update_stream_digest( + hasher: &mut blake3::Hasher, + index: usize, + assignment: &BTreeMap, + label: bool, +) -> Result<(), String> { + let index = u64::try_from(index).map_err(|_| "stream index exceeds u64".to_string())?; + let bytes = serde_json::to_vec(&(index, assignment, label)).map_err(|error| error.to_string())?; + let len = u64::try_from(bytes.len()).map_err(|_| "serialized stream item too large".to_string())?; + hasher.update(&len.to_le_bytes()); + hasher.update(&bytes); + Ok(()) +} + +fn digest_hex(hasher: &blake3::Hasher) -> String { + hasher.clone().finalize().to_hex().to_string() +} + +fn looks_like_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn kind_rank(kind: SimpleBaselineKind) -> u8 { + match kind { + SimpleBaselineKind::OneHotRls => 0, + SimpleBaselineKind::FixedRandomTanhRls => 1, + SimpleBaselineKind::VanillaHdcRls => 2, + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BaselineUpdateReceipt { + pub kind: SimpleBaselineKind, + /// RLS target-minus-prediction residual before the update. + pub residual: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BaselineEvaluationReceipt { + pub kind: SimpleBaselineKind, + pub score: f64, + pub prediction: bool, + pub expected_label: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaselinePanelMemberSnapshot { + pub kind: SimpleBaselineKind, + pub spec_digest: String, + pub updates: usize, + pub resources: BaselineResourceFootprint, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MatchedBaselinePanelSnapshot { + pub schema: String, + pub training_observations: usize, + pub evaluation_observations: usize, + pub training_stream_digest: String, + pub evaluation_stream_digest: String, + pub members: Vec, +} + +impl MatchedBaselinePanelSnapshot { + pub fn validate(&self) -> Result<(), String> { + if self.schema != MATCHED_BASELINE_PANEL_SCHEMA_V1 { + return Err(format!("unsupported matched-baseline panel schema: {}", self.schema)); + } + if !looks_like_digest(&self.training_stream_digest) + || !looks_like_digest(&self.evaluation_stream_digest) + { + return Err("panel stream digests must be 32-byte hex digests".into()); + } + if self.members.len() != 3 { + return Err("B1 matched panel must contain exactly three baseline members".into()); + } + let mut kinds = Vec::with_capacity(self.members.len()); + for member in &self.members { + if kinds.contains(&member.kind) { + return Err("B1 matched panel contains a duplicate baseline kind".into()); + } + kinds.push(member.kind); + if !looks_like_digest(&member.spec_digest) { + return Err("baseline member spec digest must be a 32-byte hex digest".into()); + } + if member.updates != self.training_observations { + return Err(format!( + "baseline {:?} update count {} does not match panel training count {}", + member.kind, member.updates, self.training_observations + )); + } + } + for required in [ + SimpleBaselineKind::OneHotRls, + SimpleBaselineKind::FixedRandomTanhRls, + SimpleBaselineKind::VanillaHdcRls, + ] { + if !kinds.contains(&required) { + return Err(format!("B1 matched panel is missing baseline kind {required:?}")); + } + } + Ok(()) + } + + /// Canonical digest over the exact baseline specs/resources, matched exposure + /// streams, and update counts. Member order is normalized by baseline kind so + /// serialization order cannot create a fake scientific variant. + pub fn digest(&self) -> Result { + self.validate()?; + let mut canonical_members = self.members.clone(); + canonical_members.sort_by_key(|member| kind_rank(member.kind)); + let bytes = serde_json::to_vec(&( + self.schema.as_str(), + self.training_observations, + self.evaluation_observations, + self.training_stream_digest.as_str(), + self.evaluation_stream_digest.as_str(), + canonical_members, + )) + .map_err(|error| error.to_string())?; + let mut hasher = initialized_hasher(PANEL_SNAPSHOT_DOMAIN); + hasher.update(&bytes); + Ok(hasher.finalize().to_hex().to_string()) + } +} + +#[derive(Debug, Clone)] +pub struct MatchedBaselinePanel { + agents: Vec, + training_observations: usize, + evaluation_observations: usize, + training_hasher: blake3::Hasher, + evaluation_hasher: blake3::Hasher, +} + +impl MatchedBaselinePanel { + pub fn new(family: &MatchedBaselineFamilySpec) -> Result { + let agents = family.agents()?; + if agents.len() != 3 { + return Err("B1 matched family must emit exactly three agents".into()); + } + Ok(Self { + agents, + training_observations: 0, + evaluation_observations: 0, + training_hasher: initialized_hasher(TRAIN_STREAM_DOMAIN), + evaluation_hasher: initialized_hasher(EVAL_STREAM_DOMAIN), + }) + } + + pub fn training_observations(&self) -> usize { + self.training_observations + } + + pub fn evaluation_observations(&self) -> usize { + self.evaluation_observations + } + + /// Feed one labeled training item to every baseline atomically. + /// + /// All agents are updated on clones first. If any encoder/readout rejects the + /// item, no agent state, update counter, or stream digest changes. + pub fn observe_all( + &mut self, + assignment: &BTreeMap, + label: bool, + ) -> Result, String> { + let next_count = self + .training_observations + .checked_add(1) + .ok_or_else(|| "training observation counter overflow".to_string())?; + let mut next_agents = self.agents.clone(); + let mut receipts = Vec::with_capacity(next_agents.len()); + for agent in &mut next_agents { + let residual = agent.observe(assignment, label).map_err(|error| { + format!("baseline {:?} rejected matched training item: {error}", agent.kind()) + })?; + receipts.push(BaselineUpdateReceipt { + kind: agent.kind(), + residual, + }); + } + + let mut next_hasher = self.training_hasher.clone(); + update_stream_digest( + &mut next_hasher, + self.training_observations, + assignment, + label, + )?; + + self.agents = next_agents; + self.training_hasher = next_hasher; + self.training_observations = next_count; + Ok(receipts) + } + + /// Evaluate every baseline on the same labeled item without changing model + /// state. The evaluation exposure is recorded only after all models score it. + pub fn evaluate_all( + &mut self, + assignment: &BTreeMap, + expected_label: bool, + ) -> Result, String> { + let next_count = self + .evaluation_observations + .checked_add(1) + .ok_or_else(|| "evaluation observation counter overflow".to_string())?; + let mut receipts = Vec::with_capacity(self.agents.len()); + for agent in &self.agents { + let score = agent.score(assignment).map_err(|error| { + format!("baseline {:?} rejected matched evaluation item: {error}", agent.kind()) + })?; + receipts.push(BaselineEvaluationReceipt { + kind: agent.kind(), + score, + prediction: score > 0.0, + expected_label, + }); + } + + let mut next_hasher = self.evaluation_hasher.clone(); + update_stream_digest( + &mut next_hasher, + self.evaluation_observations, + assignment, + expected_label, + )?; + self.evaluation_hasher = next_hasher; + self.evaluation_observations = next_count; + Ok(receipts) + } + + pub fn snapshot(&self) -> Result { + let mut members = Vec::with_capacity(self.agents.len()); + for agent in &self.agents { + members.push(BaselinePanelMemberSnapshot { + kind: agent.kind(), + spec_digest: agent.spec_digest().to_string(), + updates: agent.updates(), + resources: agent.resources()?, + }); + } + let snapshot = MatchedBaselinePanelSnapshot { + schema: MATCHED_BASELINE_PANEL_SCHEMA_V1.into(), + training_observations: self.training_observations, + evaluation_observations: self.evaluation_observations, + training_stream_digest: digest_hex(&self.training_hasher), + evaluation_stream_digest: digest_hex(&self.evaluation_hasher), + members, + }; + snapshot.validate()?; + Ok(snapshot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::experiment_baselines::{ + CategoricalFeatureSchema, CategoricalFeatureSpec, RlsConfig, + }; + + fn family() -> MatchedBaselineFamilySpec { + MatchedBaselineFamilySpec { + feature_schema: CategoricalFeatureSchema { + features: vec![ + CategoricalFeatureSpec { + name: "a".into(), + values: vec![0, 1], + }, + CategoricalFeatureSpec { + name: "b".into(), + values: vec![0, 1], + }, + ], + }, + encoded_dimension: 16, + representation_seed: 77, + rls: RlsConfig { + ridge: 1.0, + forgetting_factor: 1.0, + include_bias: true, + }, + } + } + + fn assignment(a: i64, b: i64) -> BTreeMap { + BTreeMap::from([("a".into(), a), ("b".into(), b)]) + } + + #[test] + fn panel_keeps_training_and_evaluation_exposure_matched() { + let mut panel = MatchedBaselinePanel::new(&family()).unwrap(); + panel.observe_all(&assignment(0, 0), false).unwrap(); + panel.observe_all(&assignment(1, 0), true).unwrap(); + panel.observe_all(&assignment(0, 1), true).unwrap(); + let evaluations = panel.evaluate_all(&assignment(1, 1), false).unwrap(); + assert_eq!(evaluations.len(), 3); + + let snapshot = panel.snapshot().unwrap(); + assert_eq!(snapshot.training_observations, 3); + assert_eq!(snapshot.evaluation_observations, 1); + assert_eq!(snapshot.members.len(), 3); + assert!(snapshot.members.iter().all(|member| member.updates == 3)); + assert!(looks_like_digest(&snapshot.training_stream_digest)); + assert!(looks_like_digest(&snapshot.evaluation_stream_digest)); + assert!(looks_like_digest(&snapshot.digest().unwrap())); + } + + #[test] + fn panel_stream_digests_are_deterministic_and_order_sensitive() { + let mut first = MatchedBaselinePanel::new(&family()).unwrap(); + let mut second = MatchedBaselinePanel::new(&family()).unwrap(); + for panel in [&mut first, &mut second] { + panel.observe_all(&assignment(0, 0), false).unwrap(); + panel.observe_all(&assignment(1, 0), true).unwrap(); + panel.evaluate_all(&assignment(0, 1), true).unwrap(); + } + let a = first.snapshot().unwrap(); + let b = second.snapshot().unwrap(); + assert_eq!(a.training_stream_digest, b.training_stream_digest); + assert_eq!(a.evaluation_stream_digest, b.evaluation_stream_digest); + assert_eq!(a.digest().unwrap(), b.digest().unwrap()); + + let mut reversed = MatchedBaselinePanel::new(&family()).unwrap(); + reversed.observe_all(&assignment(1, 0), true).unwrap(); + reversed.observe_all(&assignment(0, 0), false).unwrap(); + reversed.evaluate_all(&assignment(0, 1), true).unwrap(); + let c = reversed.snapshot().unwrap(); + assert_ne!(a.training_stream_digest, c.training_stream_digest); + assert_ne!(a.digest().unwrap(), c.digest().unwrap()); + assert_eq!(a.evaluation_stream_digest, c.evaluation_stream_digest); + } + + #[test] + fn snapshot_digest_is_independent_of_member_serialization_order() { + let mut panel = MatchedBaselinePanel::new(&family()).unwrap(); + panel.observe_all(&assignment(0, 0), false).unwrap(); + let snapshot = panel.snapshot().unwrap(); + let expected = snapshot.digest().unwrap(); + let mut reordered = snapshot.clone(); + reordered.members.reverse(); + reordered.validate().unwrap(); + assert_eq!(expected, reordered.digest().unwrap()); + } + + #[test] + fn failed_training_item_is_atomic() { + let mut panel = MatchedBaselinePanel::new(&family()).unwrap(); + panel.observe_all(&assignment(0, 0), false).unwrap(); + let before = panel.snapshot().unwrap(); + + let invalid = assignment(9, 0); + assert!(panel.observe_all(&invalid, true).is_err()); + + let after = panel.snapshot().unwrap(); + assert_eq!(before, after); + } + + #[test] + fn failed_evaluation_item_does_not_change_exposure_ledger() { + let mut panel = MatchedBaselinePanel::new(&family()).unwrap(); + panel.evaluate_all(&assignment(0, 0), false).unwrap(); + let before = panel.snapshot().unwrap(); + + let invalid = assignment(0, 9); + assert!(panel.evaluate_all(&invalid, true).is_err()); + + let after = panel.snapshot().unwrap(); + assert_eq!(before, after); + } + + #[test] + fn evaluation_label_is_bound_into_the_stream_digest() { + let mut first = MatchedBaselinePanel::new(&family()).unwrap(); + let mut second = MatchedBaselinePanel::new(&family()).unwrap(); + first.evaluate_all(&assignment(1, 1), false).unwrap(); + second.evaluate_all(&assignment(1, 1), true).unwrap(); + assert_ne!( + first.snapshot().unwrap().evaluation_stream_digest, + second.snapshot().unwrap().evaluation_stream_digest + ); + } +} diff --git a/crates/domains/symthaea-psych-bench/src/experiment/baselines.rs b/crates/domains/symthaea-psych-bench/src/experiment/baselines.rs new file mode 100644 index 000000000..c27f44596 --- /dev/null +++ b/crates/domains/symthaea-psych-bench/src/experiment/baselines.rs @@ -0,0 +1,1026 @@ +// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics +// SPDX-License-Identifier: AGPL-3.0-or-later +// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root +//! Strong-simple architecture baselines for SYM-ARCH-002. +//! +//! This module deliberately starts with models that are simpler than Symthaea's +//! liquid/Hebbian architecture. The central comparison is readout-matched: +//! +//! 1. categorical one-hot features + online recursive least squares (RLS), +//! 2. fixed nonlinear random features + the same RLS readout, and +//! 3. vanilla HDC role/value binding + the same RLS readout. +//! +//! None of these baselines has temporal state, replay, or learned encoder +//! parameters. The full RLS covariance is adaptive state, however, and is +//! reported explicitly because replay-free does not mean memory-free. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use symthaea_core::hdc::ContinuousHV; + +pub const SIMPLE_BASELINE_SCHEMA_V1: &str = "symthaea.simple-baseline/v1"; +/// Hard safety ceiling for a full f64 RLS inverse-covariance matrix. +/// +/// This is not a model-selection knob. It prevents accidental multi-gigabyte +/// allocation when a caller tries to pair full-covariance RLS with Symthaea's +/// normal 16K+ HDC dimensions. Larger comparisons need a different analytic +/// readout (diagonal/low-rank/block) rather than silently exhausting memory. +pub const MAX_RLS_COVARIANCE_BYTES: usize = 512 * 1024 * 1024; + +const BASELINE_SPEC_HASH_DOMAIN: &[u8] = b"symthaea.simple-baseline.spec.hash/v1"; +const RANDOM_FEATURE_DOMAIN: &[u8] = b"symthaea.simple-baseline.random-feature/v1"; +const HDC_ROLE_DOMAIN: &[u8] = b"symthaea.simple-baseline.hdc-role/v1"; +const HDC_VALUE_DOMAIN: &[u8] = b"symthaea.simple-baseline.hdc-value/v1"; + +fn canonical_hash(domain: &[u8], value: &T) -> Result { + let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&bytes); + Ok(hasher.finalize().to_hex().to_string()) +} + +fn derived_seed(seed: u64, domain: &[u8], payload: &[u8]) -> u64 { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&seed.to_le_bytes()); + hasher.update(&[0]); + hasher.update(payload); + let digest = hasher.finalize(); + u64::from_le_bytes(digest.as_bytes()[..8].try_into().expect("eight digest bytes")) +} + +fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut value = *state; + value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + value ^ (value >> 31) +} + +fn uniform_signed(state: &mut u64) -> f64 { + let bits = splitmix64(state) >> 11; + let unit = bits as f64 * (1.0 / ((1u64 << 53) as f64)); + unit * 2.0 - 1.0 +} + +fn l2_normalize(values: &mut [f64]) -> Result<(), String> { + let norm_sq: f64 = values.iter().map(|value| value * value).sum(); + if !norm_sq.is_finite() || norm_sq <= 1e-24 { + return Err("encoded feature vector has zero/non-finite norm".into()); + } + let inv_norm = 1.0 / norm_sq.sqrt(); + for value in values { + *value *= inv_norm; + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CategoricalFeatureSpec { + pub name: String, + /// Frozen categorical support. Values must be strictly increasing. + pub values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CategoricalFeatureSchema { + /// Frozen feature order. Names must be strictly lexicographically increasing. + pub features: Vec, +} + +impl CategoricalFeatureSchema { + pub fn validate(&self) -> Result<(), String> { + if self.features.is_empty() { + return Err("categorical schema must contain at least one feature".into()); + } + let mut previous_name: Option<&str> = None; + for feature in &self.features { + let name = feature.name.trim(); + if name.is_empty() || name != feature.name { + return Err("feature names must be non-empty and already normalized".into()); + } + if let Some(previous) = previous_name { + if previous >= name { + return Err("feature names must be unique and strictly sorted".into()); + } + } + previous_name = Some(name); + if feature.values.is_empty() { + return Err(format!("feature {} has an empty domain", feature.name)); + } + if feature.values.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(format!( + "feature {} values must be unique and strictly increasing", + feature.name + )); + } + } + Ok(()) + } + + pub fn one_hot_dimension(&self) -> Result { + self.validate()?; + self.features.iter().try_fold(0usize, |total, feature| { + total + .checked_add(feature.values.len()) + .ok_or_else(|| "one-hot dimension overflow".to_string()) + }) + } + + pub fn unique_values(&self) -> Result, String> { + self.validate()?; + let mut values = BTreeSet::new(); + for feature in &self.features { + values.extend(feature.values.iter().copied()); + } + Ok(values.into_iter().collect()) + } + + fn active_one_hot_indices( + &self, + assignment: &BTreeMap, + ) -> Result, String> { + self.validate()?; + if assignment.len() != self.features.len() { + return Err("assignment feature count does not match frozen schema".into()); + } + let expected_names: BTreeSet<&str> = self.features.iter().map(|f| f.name.as_str()).collect(); + let observed_names: BTreeSet<&str> = assignment.keys().map(String::as_str).collect(); + if expected_names != observed_names { + return Err("assignment feature names do not match frozen schema".into()); + } + + let mut indices = Vec::with_capacity(self.features.len()); + let mut offset = 0usize; + for feature in &self.features { + let value = assignment + .get(&feature.name) + .copied() + .ok_or_else(|| format!("assignment missing feature {}", feature.name))?; + let local = feature + .values + .binary_search(&value) + .map_err(|_| format!("feature {} value {value} is out of schema", feature.name))?; + indices.push(offset + local); + offset += feature.values.len(); + } + Ok(indices) + } + + pub fn normalized_one_hot( + &self, + assignment: &BTreeMap, + ) -> Result, String> { + let dimension = self.one_hot_dimension()?; + let active = self.active_one_hot_indices(assignment)?; + let mut encoded = vec![0.0; dimension]; + let amplitude = 1.0 / (active.len() as f64).sqrt(); + for index in active { + encoded[index] = amplitude; + } + Ok(encoded) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RlsConfig { + /// Ridge precision. The initial inverse covariance is I / ridge. + pub ridge: f64, + /// Standard RLS forgetting factor in (0, 1]. Use 1.0 for no recency decay. + pub forgetting_factor: f64, + pub include_bias: bool, +} + +impl Default for RlsConfig { + fn default() -> Self { + Self { + ridge: 1.0, + forgetting_factor: 1.0, + include_bias: true, + } + } +} + +impl RlsConfig { + pub fn validate(&self) -> Result<(), String> { + if !self.ridge.is_finite() || self.ridge <= 0.0 { + return Err("RLS ridge must be finite and positive".into()); + } + if !self.forgetting_factor.is_finite() + || self.forgetting_factor <= 0.0 + || self.forgetting_factor > 1.0 + { + return Err("RLS forgetting factor must be in (0, 1]".into()); + } + Ok(()) + } +} + +fn rls_layout( + input_dimension: usize, + config: &RlsConfig, +) -> Result<(usize, usize, usize), String> { + config.validate()?; + if input_dimension == 0 { + return Err("RLS input dimension must be positive".into()); + } + let state_dimension = input_dimension + .checked_add(usize::from(config.include_bias)) + .ok_or_else(|| "RLS state dimension overflow".to_string())?; + let covariance_len = state_dimension + .checked_mul(state_dimension) + .ok_or_else(|| "RLS covariance allocation overflow".to_string())?; + let covariance_bytes = covariance_len + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| "RLS covariance byte count overflow".to_string())?; + if covariance_bytes > MAX_RLS_COVARIANCE_BYTES { + return Err(format!( + "full RLS covariance would require {covariance_bytes} bytes, above hard ceiling {MAX_RLS_COVARIANCE_BYTES}; use a smaller encoded dimension or a bounded-state analytic readout" + )); + } + Ok((state_dimension, covariance_len, covariance_bytes)) +} + +pub fn required_rls_covariance_bytes( + input_dimension: usize, + config: &RlsConfig, +) -> Result { + Ok(rls_layout(input_dimension, config)?.2) +} + +#[derive(Debug, Clone)] +pub struct OnlineRlsBinary { + input_dimension: usize, + state_dimension: usize, + config: RlsConfig, + weights: Vec, + inverse_covariance: Vec, + updates: usize, +} + +impl OnlineRlsBinary { + pub fn new(input_dimension: usize, config: RlsConfig) -> Result { + let (state_dimension, covariance_len, _) = rls_layout(input_dimension, &config)?; + let mut inverse_covariance = vec![0.0; covariance_len]; + let diagonal = 1.0 / config.ridge; + for index in 0..state_dimension { + inverse_covariance[index * state_dimension + index] = diagonal; + } + Ok(Self { + input_dimension, + state_dimension, + config, + weights: vec![0.0; state_dimension], + inverse_covariance, + updates: 0, + }) + } + + fn augment(&self, features: &[f64]) -> Result, String> { + if features.len() != self.input_dimension { + return Err(format!( + "RLS feature dimension mismatch: expected {}, got {}", + self.input_dimension, + features.len() + )); + } + if features.iter().any(|value| !value.is_finite()) { + return Err("RLS features must be finite".into()); + } + let mut augmented = Vec::with_capacity(self.state_dimension); + augmented.extend_from_slice(features); + if self.config.include_bias { + augmented.push(1.0); + } + Ok(augmented) + } + + pub fn score(&self, features: &[f64]) -> Result { + let x = self.augment(features)?; + Ok(self.weights.iter().zip(x).map(|(weight, value)| weight * value).sum()) + } + + pub fn predict(&self, features: &[f64]) -> Result { + Ok(self.score(features)? > 0.0) + } + + pub fn update(&mut self, features: &[f64], label: bool) -> Result { + let x = self.augment(features)?; + let n = self.state_dimension; + let mut px = vec![0.0; n]; + for row in 0..n { + let base = row * n; + let mut total = 0.0; + for col in 0..n { + total += self.inverse_covariance[base + col] * x[col]; + } + px[row] = total; + } + let x_t_px: f64 = x.iter().zip(&px).map(|(left, right)| left * right).sum(); + let denominator = self.config.forgetting_factor + x_t_px; + if !denominator.is_finite() || denominator <= 1e-15 { + return Err("RLS update denominator became non-positive/non-finite".into()); + } + + let prediction: f64 = self.weights.iter().zip(&x).map(|(w, value)| w * value).sum(); + let target = if label { 1.0 } else { -1.0 }; + let error = target - prediction; + for index in 0..n { + self.weights[index] += (px[index] / denominator) * error; + } + + // Because P is initialized symmetric, x^T P == (P x)^T. Updating by + // the symmetric outer product Px(Px)^T avoids an unnecessary second + // matrix-vector product and preserves symmetry up to roundoff. + for row in 0..n { + for col in 0..n { + let index = row * n + col; + self.inverse_covariance[index] = + (self.inverse_covariance[index] - px[row] * px[col] / denominator) + / self.config.forgetting_factor; + } + } + if self + .weights + .iter() + .chain(&self.inverse_covariance) + .any(|value| !value.is_finite()) + { + return Err("RLS state became non-finite".into()); + } + self.updates += 1; + Ok(error) + } + + pub fn updates(&self) -> usize { + self.updates + } + + pub fn input_dimension(&self) -> usize { + self.input_dimension + } + + pub fn trainable_parameters(&self) -> usize { + self.state_dimension + } + + pub fn weight_bytes(&self) -> usize { + self.weights.len() * std::mem::size_of::() + } + + pub fn covariance_bytes(&self) -> usize { + self.inverse_covariance.len() * std::mem::size_of::() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SimpleBaselineKind { + OneHotRls, + FixedRandomTanhRls, + VanillaHdcRls, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SimpleBaselineSpec { + pub schema: String, + pub kind: SimpleBaselineKind, + pub feature_schema: CategoricalFeatureSchema, + /// Used by fixed-random and HDC encoders. Must be zero for one-hot RLS. + pub encoded_dimension: usize, + /// Used by fixed-random and HDC encoders. Must be zero for one-hot RLS. + pub representation_seed: u64, + pub rls: RlsConfig, +} + +impl SimpleBaselineSpec { + pub fn validate(&self) -> Result<(), String> { + if self.schema != SIMPLE_BASELINE_SCHEMA_V1 { + return Err(format!("unsupported simple-baseline schema: {}", self.schema)); + } + self.feature_schema.validate()?; + self.rls.validate()?; + match self.kind { + SimpleBaselineKind::OneHotRls => { + if self.encoded_dimension != 0 || self.representation_seed != 0 { + return Err( + "one-hot RLS must zero irrelevant encoded-dimension/representation-seed fields" + .into(), + ); + } + let dimension = self.feature_schema.one_hot_dimension()?; + let _ = rls_layout(dimension, &self.rls)?; + } + SimpleBaselineKind::FixedRandomTanhRls | SimpleBaselineKind::VanillaHdcRls => { + if self.encoded_dimension == 0 { + return Err("encoded dimension must be positive".into()); + } + let _ = rls_layout(self.encoded_dimension, &self.rls)?; + } + } + Ok(()) + } + + pub fn digest(&self) -> Result { + self.validate()?; + canonical_hash(BASELINE_SPEC_HASH_DOMAIN, self) + } +} + +/// One frozen contract that emits all readout-matched B1 controls. +/// +/// Using this type is preferred to hand-constructing three specs because it +/// prevents accidental changes in schema/RLS settings between conditions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MatchedBaselineFamilySpec { + pub feature_schema: CategoricalFeatureSchema, + pub encoded_dimension: usize, + pub representation_seed: u64, + pub rls: RlsConfig, +} + +impl MatchedBaselineFamilySpec { + pub fn validate(&self) -> Result<(), String> { + self.feature_schema.validate()?; + self.rls.validate()?; + if self.encoded_dimension == 0 { + return Err("matched random/HDC dimension must be positive".into()); + } + let _ = rls_layout(self.encoded_dimension, &self.rls)?; + let one_hot_dimension = self.feature_schema.one_hot_dimension()?; + let _ = rls_layout(one_hot_dimension, &self.rls)?; + Ok(()) + } + + pub fn specs(&self) -> Result, String> { + self.validate()?; + Ok(vec![ + SimpleBaselineSpec { + schema: SIMPLE_BASELINE_SCHEMA_V1.into(), + kind: SimpleBaselineKind::OneHotRls, + feature_schema: self.feature_schema.clone(), + encoded_dimension: 0, + representation_seed: 0, + rls: self.rls.clone(), + }, + SimpleBaselineSpec { + schema: SIMPLE_BASELINE_SCHEMA_V1.into(), + kind: SimpleBaselineKind::FixedRandomTanhRls, + feature_schema: self.feature_schema.clone(), + encoded_dimension: self.encoded_dimension, + representation_seed: self.representation_seed, + rls: self.rls.clone(), + }, + SimpleBaselineSpec { + schema: SIMPLE_BASELINE_SCHEMA_V1.into(), + kind: SimpleBaselineKind::VanillaHdcRls, + feature_schema: self.feature_schema.clone(), + encoded_dimension: self.encoded_dimension, + representation_seed: self.representation_seed, + rls: self.rls.clone(), + }, + ]) + } + + pub fn agents(&self) -> Result, String> { + self.specs()? + .into_iter() + .map(SimpleBaselineAgent::new) + .collect() + } +} + +#[derive(Debug, Clone)] +struct FixedRandomTanhEncoder { + schema: CategoricalFeatureSchema, + input_dimension: usize, + output_dimension: usize, + weights: Vec, + bias: Vec, +} + +impl FixedRandomTanhEncoder { + fn new( + schema: CategoricalFeatureSchema, + output_dimension: usize, + seed: u64, + ) -> Result { + let input_dimension = schema.one_hot_dimension()?; + if output_dimension == 0 { + return Err("random-feature output dimension must be positive".into()); + } + let weights_len = output_dimension + .checked_mul(input_dimension) + .ok_or_else(|| "random-feature matrix allocation overflow".to_string())?; + let mut state = derived_seed(seed, RANDOM_FEATURE_DOMAIN, b"matrix-and-bias"); + let mut weights = Vec::with_capacity(weights_len); + for _ in 0..weights_len { + weights.push(uniform_signed(&mut state) as f32); + } + let mut bias = Vec::with_capacity(output_dimension); + for _ in 0..output_dimension { + bias.push(uniform_signed(&mut state) as f32); + } + Ok(Self { + schema, + input_dimension, + output_dimension, + weights, + bias, + }) + } + + fn encode(&self, assignment: &BTreeMap) -> Result, String> { + let active = self.schema.active_one_hot_indices(assignment)?; + let active_scale = 1.0 / (active.len() as f64).sqrt(); + let mut encoded = vec![0.0; self.output_dimension]; + for row in 0..self.output_dimension { + let base = row * self.input_dimension; + let mut activation = self.bias[row] as f64; + for &column in &active { + activation += self.weights[base + column] as f64 * active_scale; + } + encoded[row] = activation.tanh(); + } + l2_normalize(&mut encoded)?; + Ok(encoded) + } + + fn state_bytes(&self) -> usize { + (self.weights.len() + self.bias.len()) * std::mem::size_of::() + } +} + +#[derive(Debug, Clone)] +struct VanillaHdcEncoder { + schema: CategoricalFeatureSchema, + dimension: usize, + roles: BTreeMap, + values: BTreeMap, +} + +impl VanillaHdcEncoder { + fn new( + schema: CategoricalFeatureSchema, + dimension: usize, + seed: u64, + ) -> Result { + schema.validate()?; + if dimension == 0 { + return Err("HDC output dimension must be positive".into()); + } + let mut roles = BTreeMap::new(); + for feature in &schema.features { + let role_seed = derived_seed(seed, HDC_ROLE_DOMAIN, feature.name.as_bytes()); + roles.insert(feature.name.clone(), ContinuousHV::random(dimension, role_seed)); + } + let mut values = BTreeMap::new(); + for value in schema.unique_values()? { + let value_seed = derived_seed(seed, HDC_VALUE_DOMAIN, &value.to_le_bytes()); + values.insert(value, ContinuousHV::random(dimension, value_seed)); + } + Ok(Self { + schema, + dimension, + roles, + values, + }) + } + + fn encode(&self, assignment: &BTreeMap) -> Result, String> { + // Reuse the exact schema validation performed by one-hot encoding so the + // HDC encoder cannot silently accept additional/missing learner features. + let _ = self.schema.active_one_hot_indices(assignment)?; + let mut bound = Vec::with_capacity(self.schema.features.len()); + for feature in &self.schema.features { + let value = assignment + .get(&feature.name) + .copied() + .ok_or_else(|| format!("assignment missing feature {}", feature.name))?; + let role = self + .roles + .get(&feature.name) + .ok_or_else(|| "HDC role table is incomplete".to_string())?; + let value_hv = self + .values + .get(&value) + .ok_or_else(|| format!("HDC value {value} is outside frozen schema"))?; + bound.push(role.bind(value_hv)); + } + let refs: Vec<&ContinuousHV> = bound.iter().collect(); + let bundled = ContinuousHV::bundle(&refs).normalize(); + if bundled.values.len() != self.dimension { + return Err("HDC encoder produced unexpected dimension".into()); + } + let mut encoded: Vec = bundled.values.iter().map(|value| *value as f64).collect(); + l2_normalize(&mut encoded)?; + Ok(encoded) + } + + fn state_bytes(&self) -> usize { + let vector_count = self.roles.len() + self.values.len(); + vector_count * self.dimension * std::mem::size_of::() + } +} + +#[derive(Debug, Clone)] +enum Encoder { + OneHot(CategoricalFeatureSchema), + FixedRandom(FixedRandomTanhEncoder), + VanillaHdc(VanillaHdcEncoder), +} + +impl Encoder { + fn feature_dimension(&self) -> usize { + match self { + Self::OneHot(schema) => schema + .one_hot_dimension() + .expect("validated one-hot schema remains valid"), + Self::FixedRandom(encoder) => encoder.output_dimension, + Self::VanillaHdc(encoder) => encoder.dimension, + } + } + + fn encode(&self, assignment: &BTreeMap) -> Result, String> { + match self { + Self::OneHot(schema) => schema.normalized_one_hot(assignment), + Self::FixedRandom(encoder) => encoder.encode(assignment), + Self::VanillaHdc(encoder) => encoder.encode(assignment), + } + } + + fn fixed_state_bytes(&self) -> usize { + match self { + Self::OneHot(_) => 0, + Self::FixedRandom(encoder) => encoder.state_bytes(), + Self::VanillaHdc(encoder) => encoder.state_bytes(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaselineResourceFootprint { + pub feature_dimension: usize, + pub fixed_encoder_state_bytes: usize, + pub readout_weight_bytes: usize, + pub readout_covariance_bytes: usize, + pub total_persistent_state_bytes: usize, + pub trainable_parameters: usize, + pub replay_examples: usize, + pub temporal_state_bytes: usize, +} + +#[derive(Debug, Clone)] +pub struct SimpleBaselineAgent { + spec: SimpleBaselineSpec, + spec_digest: String, + encoder: Encoder, + readout: OnlineRlsBinary, +} + +impl SimpleBaselineAgent { + pub fn new(spec: SimpleBaselineSpec) -> Result { + spec.validate()?; + let spec_digest = spec.digest()?; + let encoder = match spec.kind { + SimpleBaselineKind::OneHotRls => Encoder::OneHot(spec.feature_schema.clone()), + SimpleBaselineKind::FixedRandomTanhRls => Encoder::FixedRandom( + FixedRandomTanhEncoder::new( + spec.feature_schema.clone(), + spec.encoded_dimension, + spec.representation_seed, + )?, + ), + SimpleBaselineKind::VanillaHdcRls => Encoder::VanillaHdc(VanillaHdcEncoder::new( + spec.feature_schema.clone(), + spec.encoded_dimension, + spec.representation_seed, + )?), + }; + let readout = OnlineRlsBinary::new(encoder.feature_dimension(), spec.rls.clone())?; + Ok(Self { + spec, + spec_digest, + encoder, + readout, + }) + } + + pub fn kind(&self) -> SimpleBaselineKind { + self.spec.kind + } + + pub fn spec(&self) -> &SimpleBaselineSpec { + &self.spec + } + + pub fn spec_digest(&self) -> &str { + &self.spec_digest + } + + pub fn encoded_features( + &self, + assignment: &BTreeMap, + ) -> Result, String> { + self.encoder.encode(assignment) + } + + pub fn score(&self, assignment: &BTreeMap) -> Result { + let encoded = self.encoder.encode(assignment)?; + self.readout.score(&encoded) + } + + pub fn predict(&self, assignment: &BTreeMap) -> Result { + let encoded = self.encoder.encode(assignment)?; + self.readout.predict(&encoded) + } + + pub fn observe( + &mut self, + assignment: &BTreeMap, + label: bool, + ) -> Result { + let encoded = self.encoder.encode(assignment)?; + self.readout.update(&encoded, label) + } + + pub fn updates(&self) -> usize { + self.readout.updates() + } + + pub fn resources(&self) -> Result { + let fixed_encoder_state_bytes = self.encoder.fixed_state_bytes(); + let readout_weight_bytes = self.readout.weight_bytes(); + let readout_covariance_bytes = self.readout.covariance_bytes(); + let total_persistent_state_bytes = fixed_encoder_state_bytes + .checked_add(readout_weight_bytes) + .and_then(|value| value.checked_add(readout_covariance_bytes)) + .ok_or_else(|| "baseline resource accounting overflow".to_string())?; + Ok(BaselineResourceFootprint { + feature_dimension: self.readout.input_dimension(), + fixed_encoder_state_bytes, + readout_weight_bytes, + readout_covariance_bytes, + total_persistent_state_bytes, + trainable_parameters: self.readout.trainable_parameters(), + replay_examples: 0, + temporal_state_bytes: 0, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema() -> CategoricalFeatureSchema { + CategoricalFeatureSchema { + features: vec![ + CategoricalFeatureSpec { + name: "a".into(), + values: vec![0, 1], + }, + CategoricalFeatureSpec { + name: "b".into(), + values: vec![0, 1], + }, + ], + } + } + + fn assignment(a: i64, b: i64) -> BTreeMap { + BTreeMap::from([("a".into(), a), ("b".into(), b)]) + } + + fn spec(kind: SimpleBaselineKind, dimension: usize, seed: u64) -> SimpleBaselineSpec { + let (encoded_dimension, representation_seed) = match kind { + SimpleBaselineKind::OneHotRls => (0, 0), + _ => (dimension, seed), + }; + SimpleBaselineSpec { + schema: SIMPLE_BASELINE_SCHEMA_V1.into(), + kind, + feature_schema: schema(), + encoded_dimension, + representation_seed, + rls: RlsConfig { + ridge: 1.0, + forgetting_factor: 1.0, + include_bias: true, + }, + } + } + + fn family(dimension: usize, seed: u64) -> MatchedBaselineFamilySpec { + MatchedBaselineFamilySpec { + feature_schema: schema(), + encoded_dimension: dimension, + representation_seed: seed, + rls: RlsConfig { + ridge: 1.0, + forgetting_factor: 1.0, + include_bias: true, + }, + } + } + + #[test] + fn schema_is_strict_and_one_hot_is_unit_norm() { + let schema = schema(); + schema.validate().unwrap(); + assert_eq!(schema.one_hot_dimension().unwrap(), 4); + let encoded = schema.normalized_one_hot(&assignment(1, 0)).unwrap(); + assert_eq!(encoded.iter().filter(|value| **value != 0.0).count(), 2); + let norm: f64 = encoded.iter().map(|value| value * value).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-12); + + let mut invalid = assignment(1, 0); + invalid.insert("hidden_task_id".into(), 7); + assert!(schema.normalized_one_hot(&invalid).is_err()); + } + + #[test] + fn rls_learns_a_simple_online_separator_without_replay() { + let mut rls = OnlineRlsBinary::new( + 1, + RlsConfig { + ridge: 0.1, + forgetting_factor: 1.0, + include_bias: true, + }, + ) + .unwrap(); + for _ in 0..12 { + rls.update(&[-1.0], false).unwrap(); + rls.update(&[1.0], true).unwrap(); + } + assert!(!rls.predict(&[-1.0]).unwrap()); + assert!(rls.predict(&[1.0]).unwrap()); + assert_eq!(rls.updates(), 24); + assert!(rls.covariance_bytes() > rls.weight_bytes()); + } + + #[test] + fn full_rls_rejects_multi_gigabyte_covariance_before_allocation() { + let error = required_rls_covariance_bytes(16_384, &RlsConfig::default()).unwrap_err(); + assert!(error.contains("hard ceiling")); + } + + #[test] + fn one_hot_spec_rejects_irrelevant_randomness_knobs() { + let mut invalid = spec(SimpleBaselineKind::OneHotRls, 64, 7); + invalid.representation_seed = 7; + assert!(invalid.validate().is_err()); + invalid.representation_seed = 0; + invalid.encoded_dimension = 64; + assert!(invalid.validate().is_err()); + } + + #[test] + fn fixed_random_encoder_is_seed_deterministic() { + let first = SimpleBaselineAgent::new(spec( + SimpleBaselineKind::FixedRandomTanhRls, + 64, + 17, + )) + .unwrap(); + let second = SimpleBaselineAgent::new(spec( + SimpleBaselineKind::FixedRandomTanhRls, + 64, + 17, + )) + .unwrap(); + let different = SimpleBaselineAgent::new(spec( + SimpleBaselineKind::FixedRandomTanhRls, + 64, + 18, + )) + .unwrap(); + let x = assignment(1, 0); + let a = first.encoded_features(&x).unwrap(); + let b = second.encoded_features(&x).unwrap(); + let c = different.encoded_features(&x).unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + let norm = a.iter().map(|value| value * value).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-10); + } + + #[test] + fn vanilla_hdc_encoder_is_seed_deterministic_and_normalized() { + let first = SimpleBaselineAgent::new(spec(SimpleBaselineKind::VanillaHdcRls, 64, 23)).unwrap(); + let second = SimpleBaselineAgent::new(spec(SimpleBaselineKind::VanillaHdcRls, 64, 23)).unwrap(); + let different = + SimpleBaselineAgent::new(spec(SimpleBaselineKind::VanillaHdcRls, 64, 24)).unwrap(); + let x = assignment(0, 1); + let a = first.encoded_features(&x).unwrap(); + let b = second.encoded_features(&x).unwrap(); + let c = different.encoded_features(&x).unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(a.len(), 64); + let norm = a.iter().map(|value| value * value).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-8); + } + + #[test] + fn matched_family_freezes_one_readout_contract_without_allocating_on_validate() { + let family = family(128, 31); + family.validate().unwrap(); + let specs = family.specs().unwrap(); + assert_eq!(specs.len(), 3); + assert_eq!(specs[0].kind, SimpleBaselineKind::OneHotRls); + assert_eq!(specs[1].kind, SimpleBaselineKind::FixedRandomTanhRls); + assert_eq!(specs[2].kind, SimpleBaselineKind::VanillaHdcRls); + assert_eq!(specs[0].rls, specs[1].rls); + assert_eq!(specs[1].rls, specs[2].rls); + assert_eq!(specs[0].feature_schema, specs[1].feature_schema); + assert_eq!(specs[1].feature_schema, specs[2].feature_schema); + assert_eq!(specs[0].encoded_dimension, 0); + assert_eq!(specs[0].representation_seed, 0); + assert_eq!(specs[1].encoded_dimension, 128); + assert_eq!(specs[2].encoded_dimension, 128); + assert_eq!(specs[1].representation_seed, 31); + assert_eq!(specs[2].representation_seed, 31); + } + + #[test] + fn random_and_hdc_conditions_share_exact_readout_state_shape() { + let agents = family(128, 31).agents().unwrap(); + let random = agents + .iter() + .find(|agent| agent.kind() == SimpleBaselineKind::FixedRandomTanhRls) + .unwrap(); + let hdc = agents + .iter() + .find(|agent| agent.kind() == SimpleBaselineKind::VanillaHdcRls) + .unwrap(); + let random_resources = random.resources().unwrap(); + let hdc_resources = hdc.resources().unwrap(); + assert_eq!(random_resources.feature_dimension, 128); + assert_eq!(hdc_resources.feature_dimension, 128); + assert_eq!(random_resources.readout_weight_bytes, hdc_resources.readout_weight_bytes); + assert_eq!( + random_resources.readout_covariance_bytes, + hdc_resources.readout_covariance_bytes + ); + assert_eq!(random_resources.trainable_parameters, hdc_resources.trainable_parameters); + assert_eq!(random_resources.replay_examples, 0); + assert_eq!(hdc_resources.replay_examples, 0); + assert_eq!(random_resources.temporal_state_bytes, 0); + assert_eq!(hdc_resources.temporal_state_bytes, 0); + } + + #[test] + fn encoder_state_is_label_independent() { + let mut agent = SimpleBaselineAgent::new(spec( + SimpleBaselineKind::FixedRandomTanhRls, + 32, + 41, + )) + .unwrap(); + let x = assignment(1, 1); + let before = agent.encoded_features(&x).unwrap(); + for label in [true, false, true, true, false] { + agent.observe(&x, label).unwrap(); + } + let after = agent.encoded_features(&x).unwrap(); + assert_eq!(before, after); + assert_eq!(agent.updates(), 5); + } + + #[test] + fn spec_digest_binds_kind_seed_schema_and_readout() { + let raw = spec(SimpleBaselineKind::OneHotRls, 64, 5); + let random = spec(SimpleBaselineKind::FixedRandomTanhRls, 64, 5); + let other_seed = spec(SimpleBaselineKind::FixedRandomTanhRls, 64, 6); + assert_ne!(raw.digest().unwrap(), random.digest().unwrap()); + assert_ne!(random.digest().unwrap(), other_seed.digest().unwrap()); + } + + #[test] + fn resource_accounting_exposes_full_rls_covariance_cost() { + let agent = SimpleBaselineAgent::new(spec( + SimpleBaselineKind::FixedRandomTanhRls, + 64, + 7, + )) + .unwrap(); + let resources = agent.resources().unwrap(); + assert_eq!(resources.feature_dimension, 64); + assert_eq!(resources.trainable_parameters, 65); // 64 features + bias + assert_eq!(resources.readout_weight_bytes, 65 * 8); + assert_eq!(resources.readout_covariance_bytes, 65 * 65 * 8); + assert_eq!(resources.replay_examples, 0); + assert_eq!(resources.temporal_state_bytes, 0); + assert_eq!( + resources.total_persistent_state_bytes, + resources.fixed_encoder_state_bytes + + resources.readout_weight_bytes + + resources.readout_covariance_bytes + ); + } +} \ No newline at end of file diff --git a/crates/domains/symthaea-psych-bench/src/lib.rs b/crates/domains/symthaea-psych-bench/src/lib.rs index 634ef01f5..055fefe9e 100644 --- a/crates/domains/symthaea-psych-bench/src/lib.rs +++ b/crates/domains/symthaea-psych-bench/src/lib.rs @@ -54,6 +54,12 @@ pub mod adapter; pub mod benchmarks; pub mod experiment; +#[path = "experiment/baseline_fairness.rs"] +pub mod experiment_baseline_fairness; +#[path = "experiment/baseline_panel.rs"] +pub mod experiment_baseline_panel; +#[path = "experiment/baselines.rs"] +pub mod experiment_baselines; #[path = "experiment/confirmatory.rs"] pub mod experiment_confirmatory; #[path = "experiment/validity.rs"] diff --git a/docs/research/SYM_ARCH_002B1_STRONG_SIMPLE_BASELINES_V1.md b/docs/research/SYM_ARCH_002B1_STRONG_SIMPLE_BASELINES_V1.md new file mode 100644 index 000000000..f79bb9de2 --- /dev/null +++ b/docs/research/SYM_ARCH_002B1_STRONG_SIMPLE_BASELINES_V1.md @@ -0,0 +1,263 @@ +# SYM-ARCH-002B1 — Strong-Simple Analytic Baselines v1 + +**Status:** implementation contract; no architecture result + +**Tracks:** #55 + +**Depends on:** the SYM-ARCH-002 experimental core and generated-task validity work. This tranche defines baseline mechanisms only; a later experiment binds them to a frozen DEV/CONFIRM/REPL manifest and benchmark generator. + +## Question + +Before adding GRU, trainable SSM, Mamba, liquid dynamics, or Hebbian plasticity, test the simplest serious alternative explanation for a future Symthaea architecture result: + +> Is the apparent advantage already explained by a fixed representation plus a strong online analytic readout? + +B1 deliberately contains no replay and no temporal state. + +## Baseline ladder + +All three conditions use the same `OnlineRlsBinary` update rule and frozen `RlsConfig`: + +1. `one_hot_rls` + - normalized categorical one-hot input; + - no representation seed; + - no encoded-dimension knob; + - full-covariance online RLS readout. + +2. `fixed_random_tanh_rls` + - the same frozen categorical schema; + - deterministic fixed random projection from pair-specific one-hot features; + - `tanh` nonlinearity; + - L2-normalized output; + - full-covariance online RLS readout. + +3. `vanilla_hdc_rls` + - the same frozen categorical schema; + - deterministic feature-role HVs; + - shared categorical-value HVs across roles; + - `ContinuousHV` role/value binding and bundling; + - L2-normalized output; + - the exact same full-covariance online RLS readout shape as the random condition when encoded dimensions match. + +`MatchedBaselineFamilySpec` is the preferred construction path. It emits all three conditions from one feature-schema/RLS contract so the random and HDC controls cannot quietly receive different ridge, forgetting, bias, schema, or encoded dimensions. + +The phrase **readout-matched** is reserved for a pair whose *effective* RLS input/state shape is actually equal. Sharing the same RLS algorithm/configuration is not by itself sufficient when the encoder output dimensions differ. + +## Why include one-hot RLS? + +The raw categorical condition tells us whether a strong analytic readout already solves the task without any high-dimensional representation. + +Interpretation must respect the executable contrast audit: + +- `hdc ≈ random` under a representation-level audit: HDC receives no evidence beyond generic fixed random features; +- `hdc > random` under a representation-level audit: representation-level evidence for the factorized HDC implementation; +- `random > hdc` under a representation-level audit: HDC is less useful in that regime; +- `one_hot` versus a higher-dimensional random/HDC condition is a **reference/capacity comparison** when effective readout dimensions differ; +- therefore `random > one_hot` does **not** by itself isolate a benefit from nonlinear expansion when the RLS trainable/covariance shape also increased; +- if one-hot and encoded dimensions happen to match and every other fairness invariant passes, the audit may elevate that pair to a representation-level contrast. + +A B1 HDC advantage does **not** isolate HDC binding algebra from every other representation difference. The random encoder is pair-feature based, whereas HDC explicitly shares value identities across roles and composes them through binding. Exact algebraic attribution requires a later matched factorization/binding ablation, including the planned continuous-vs-exact bipolar/BinaryHV tranche. + +## Executable contrast-fairness audit + +`experiment_baseline_fairness::audit_baseline_contrast` validates both specs and records: + +- exact spec digests; +- learner-visible feature-schema equality; +- exact RLS contract equality; +- effective feature dimensions; +- trainable readout state dimensions; +- inverse-covariance shapes; +- paired representation-seed index when both encoders are randomized; +- replay count (`0` for B1); +- temporal-state bytes (`0` for B1); +- explicit qualifiers for every mismatch. + +It returns one of two non-scalar claim ceilings: + +- `representation_level` — schema, RLS protocol, effective readout shape, and applicable random-seed pairing are matched; +- `reference_only` — capacity/protocol differs enough that a representation-only attribution is not admissible. + +This audit intentionally does **not** require equal fixed-encoder storage. Encoder storage is part of the representation's resource cost and must be reported separately. A representation-level predictive contrast is therefore not automatically a resource-normalized comparison. + +`audit_matched_family` audits all three pairwise contrasts emitted by one `MatchedBaselineFamilySpec`. In the usual B1 regime where the encoded random/HDC dimension is much larger than the categorical one-hot dimension, random↔HDC should be representation-level while one-hot↔random and one-hot↔HDC remain reference-only. + +## Matched data and update opportunities + +Static configuration equality is not enough for a continual-learning comparison. A runner could still accidentally feed models different examples, different labels, a different order, or a different number of updates. + +`experiment_baseline_panel::MatchedBaselinePanel` is therefore the preferred claim-bearing B1 execution path. + +It owns all three baseline agents and provides: + +- `observe_all` — applies one labeled training item to every baseline; +- clone-before-commit semantics — if any model rejects the item, **no** baseline state, update counter, or training-stream digest changes; +- `evaluate_all` — scores every baseline on the same labeled evaluation item without changing model state; +- a domain-separated, order-sensitive training-stream digest; +- a separate domain-separated, order-sensitive evaluation-stream digest including the expected label; +- per-member spec digests, update counts, and resource footprints; +- a canonical panel-snapshot digest independent of member serialization order. + +The snapshot fails validation unless: + +- exactly one one-hot, one random, and one HDC baseline are present; +- every member has a well-formed spec digest; +- every member's update count exactly equals the panel training-observation count. + +For a claim-bearing B1 result, bind the panel snapshot digest alongside the experiment manifest, task-program/dataset identity, analysis revision, and relevant contrast audit. A hand-written loop over independent agents may be useful during development, but it is not sufficient evidence for the statement that exposure/update opportunities were matched. + +The panel digest does not replace raw result artifacts. It is a compact provenance commitment to the exact baseline specs/resources and the exact ordered training/evaluation exposure streams used by the matched panel. + +## This is RanDumb/F-OAL-inspired, not a reproduction + +The fixed-random and analytic-readout controls are motivated by recent online continual-learning results showing that frozen random representations and forward-only analytic classifiers can be unexpectedly strong. + +B1 does not claim to reproduce a named external method exactly. Its random transform, categorical schema, and RLS implementation are benchmark-local and explicitly versioned. Any external-method reproduction should be a separate adapter with its original algorithm/configuration documented. + +## RLS contract + +`OnlineRlsBinary` uses binary targets `{-1,+1}` and standard recursive least squares with: + +- frozen positive ridge precision; +- forgetting factor in `(0, 1]`; +- optional bias term; +- symmetric rank-one inverse-covariance update; +- no replay buffer; +- no learned encoder. + +The readout has trainable weight count `d (+ 1 when bias is enabled)` but maintains a full `f64` inverse-covariance matrix of the same state dimension. + +### Replay-free is not memory-free + +Full RLS state is `O(d^2)`. + +Every B1 result must report separately: + +- encoded feature dimension; +- fixed encoder bytes; +- readout weight bytes; +- inverse-covariance bytes; +- total persistent state bytes; +- trainable parameter count; +- replay examples (`0` here); +- temporal-state bytes (`0` here). + +The implementation has a hard 512 MiB covariance ceiling. This is a safety invariant, not a scientific hyperparameter. A request above the ceiling fails before allocation. In particular, full-covariance RLS must not be naively instantiated at Symthaea's ordinary 16K HDC dimension. + +Large-dimension comparisons need a separately specified bounded-state analytic readout (for example diagonal, block, sketch, or low-rank) with its own evidence tranche; they must not silently change the B1 algorithm. + +## Frozen categorical schema + +`CategoricalFeatureSchema` is strict: + +- feature names are normalized, unique, and sorted; +- value domains are explicit, unique, and sorted; +- assignments must contain exactly the declared learner-visible features; +- out-of-domain values fail closed; +- one-hot vectors are normalized. + +For claim-bearing experiments the schema comes from the preregistered generator/reference task specification. It must not be discovered by looking at CONFIRM labels or changed after observing CONFIRM behavior. + +Task/world/boundary metadata remains subject to the A3/A4 validity policies; B1 does not authorize hidden task identity merely because a field can be represented categorically. + +## Determinism and provenance + +Random and HDC encoder state is deterministic from the versioned implementation plus the frozen: + +- baseline kind; +- categorical schema; +- encoded dimension; +- representation seed. + +The one-hot condition has no random representation. Its `encoded_dimension` and `representation_seed` fields are required to be exactly zero so irrelevant parameters cannot create fake experimental variants or degrees of freedom. + +Each emitted baseline spec has a domain-separated BLAKE3 digest over its versioned schema/configuration. The spec digest does **not** pretend to be a git-revision digest. Exact source identity belongs in the experiment manifest's `code_revision` and later claim/evidence binding. A claim-bearing artifact must bind both the frozen baseline-spec digest(s) and the exact code revision used to execute them. + +## What must be frozen before CONFIRM + +At minimum: + +- all emitted baseline spec digests; +- exact code revision and baseline schema version; +- exact categorical schema; +- encoded dimension for random/HDC; +- representation-seed manifest; +- RLS ridge; +- RLS forgetting factor; +- bias policy; +- stream/order/environment seeds; +- update count / observation budget; +- evaluation points; +- primary comparator and metric; +- SESOI and analysis rule; +- resource budget/fairness regime; +- expected contrast-fairness claim ceiling for every primary comparison; +- matched-panel schema/version and required panel-snapshot binding. + +DEV may be used to choose these values. CONFIRM may not be used to tune them. + +`RlsConfig::default()` and unit-test dimensions are software conveniences, not preregistered scientific defaults. + +## Fairness regimes + +B1 supports distinct comparisons and they must not be conflated. + +### Readout-matched representation comparison + +`fixed_random_tanh_rls` vs `vanilla_hdc_rls` at the same encoded dimension, paired representation-seed index, exact same learner-visible schema, and exact same RLS configuration. + +This is the primary representation comparison and should pass the executable fairness audit as `representation_level`. + +### Simpler-model / capacity reference + +`one_hot_rls` usually has a smaller feature/readout dimension than the high-dimensional conditions. + +It is intentionally a lower-complexity baseline. If it is practically equivalent, the simpler model may still be preferable under a separately frozen complexity/resource policy. But a performance difference against a larger random/HDC readout is not, by itself, a clean representation-only effect. + +Later resource-normalized tranches may additionally compare at matched persistent bytes or update compute. B1 does not invent a single weighted score across capability and resource dimensions. + +## Acceptance tests + +The implementation tranche is acceptable when the exact PR head demonstrates: + +1. strict deterministic categorical-schema encoding; +2. online RLS learns a simple separator without replay; +3. oversized full-covariance RLS is rejected before allocation; +4. one-hot specs reject irrelevant seed/dimension knobs; +5. fixed-random encoding is seed deterministic; +6. vanilla HDC encoding is seed deterministic and normalized; +7. matched-family specs share one RLS/schema contract; +8. matched random/HDC conditions have identical readout state shape; +9. encoder state remains fixed while labels/readout updates change; +10. spec digests change when meaningful baseline configuration changes; +11. resource accounting exposes the full covariance cost; +12. random↔HDC receives a representation-level audit under the matched family; +13. dimension-mismatched one-hot↔random is downgraded to reference-only; +14. randomized representation contrasts with unpaired seed indices are downgraded; +15. RLS protocol mismatch is downgraded; +16. matched-panel training/evaluation streams are deterministic and order-sensitive; +17. failed training/evaluation items leave panel exposure state unchanged; +18. every matched-panel member update count equals the panel training count; +19. the canonical panel snapshot digest is stable under member serialization reordering; +20. B1 Rust files pass rustfmt; +21. the psych-bench library compiles. + +## Wording ceiling + +Merging B1 supports only: + +> Symthaea psych-bench contains deterministic, resource-audited strong-simple one-hot, fixed-random, and vanilla-HDC online-RLS baselines plus executable contrast-fairness and matched-exposure provenance suitable for later preregistered architecture comparisons. + +It does not support: + +- an architecture-performance claim; +- a claim that HDC beats random features; +- a representation-only interpretation for a contrast that the fairness audit marks `reference_only`; +- a matched-stream claim from a run that does not bind a valid matched-panel snapshot; +- a claim that RLS is a faithful reproduction of a named published method; +- a claim that HDC algebra has been isolated; +- a claim about liquid dynamics, Hebbian plasticity, or full Symthaea intelligence. + +## Next step after infrastructure validation + +Use B1 only after the A-series validity/statistics infrastructure is green enough to construct a DEV campaign. The first empirical goal is to measure whether fixed random nonlinear features plus RLS explain any apparent representation benefit before adding temporal or plastic mechanisms.