Weksa/crates/weksa/src/split.rs
Weksa de46ced9ea Round lows 2: length-framed prompt source digest, refused create takes its lock, Prompts drift text, window pins for entities and head, one-write asserts Accepted
Recorded prompt digests change with the framing (they drift by design).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 08:40:35 +00:00

219 lines
6.9 KiB
Rust

//! The train/validation/test split is derived from an item's group and nothing
//! else. No stored assignment exists, so no writer can reassign one. An item is
//! read in its live split. The round path (round records, prompts, `aggregate`,
//! `leak_diagnostic`) carries no Test reading; a typed `TestReadings` and a Test
//! `EncoderOffer` need a Checkpoint, and only the lib can make one
//! (convergence-freeze adds the door). Operator tools (`weksa corpus dump`,
//! `weksa score`, `CorpusStore`) read corpus text of any split; that is not
//! gated here.
use std::collections::BTreeSet;
use crate::corpus::{CorpusItem, Source};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Split {
Train,
Validation,
Test,
}
/// A scheme is a constant; a new scheme is a new variant, never a reassignment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitScheme {
V1,
}
pub const fn fnv1a64(bytes: &[u8]) -> u64 {
fnv1a64_fold(0xcbf2_9ce4_8422_2325, bytes)
}
/// FNV-1a 64 of the parts, each preceded by its length as eight little-endian
/// bytes, so no two different splits of the same bytes share a digest.
pub const fn fnv1a64_of(parts: &[&[u8]]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut p = 0;
while p < parts.len() {
h = fnv1a64_fold(h, &(parts[p].len() as u64).to_le_bytes());
h = fnv1a64_fold(h, parts[p]);
p += 1;
}
h
}
const fn fnv1a64_fold(mut h: u64, bytes: &[u8]) -> u64 {
let mut i = 0;
while i < bytes.len() {
h ^= bytes[i] as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
i += 1;
}
h
}
/// 16 lowercase hex digits, zero-padded: how a digest is written everywhere.
pub(crate) fn hex16(n: u64) -> String {
format!("{n:016x}")
}
impl SplitScheme {
/// 0..100, FNV-1a 64 of `weksa-split-v1:` + group, mod 100.
pub fn bucket(self, group: &str) -> u64 {
match self {
SplitScheme::V1 => fnv1a64(format!("weksa-split-v1:{group}").as_bytes()) % 100,
}
}
/// <40 Train, <80 Validation, else Test.
pub fn of_bucket(self, bucket: u64) -> Split {
match self {
SplitScheme::V1 if bucket < 40 => Split::Train,
SplitScheme::V1 if bucket < 80 => Split::Validation,
SplitScheme::V1 => Split::Test,
}
}
/// The split of an item: its group's, except that a model-written item of a
/// test group is in no split. It is never moved to train: a model line of
/// the test meaning would teach the test.
pub fn split(self, item: &CorpusItem) -> Option<Split> {
match self.of_bucket(self.bucket(&item.group)) {
Split::Test if matches!(item.source, Source::ModelWritten { .. }) => None,
split => Some(split),
}
}
/// The split an item is read in; a withdrawn item is read in none.
pub fn live(self, item: &CorpusItem) -> Option<Split> {
if item.withdrawn {
None
} else {
self.split(item)
}
}
}
/// Permission to read Test once, as typed `TestReadings` or as Test
/// `EncoderOffer`s. Only the weksa lib can make one: there is no public
/// constructor, so no code outside it can obtain those. This is not a seal on
/// corpus text: operator tools and `CorpusStore` can read any item, and the
/// guarded invariant is that the round path holds no Test reading. The one door
/// is added by
/// convergence-freeze, which builds a `Checkpoint` from the convergence window
/// over the run's persisted round records (convergence-test).
/// `TestReadings::at_checkpoint` consumes it, so one `Checkpoint` buys one Test
/// reading. One `Checkpoint` also offers each item at most once
/// (`EncoderOffer::for_checkpoint`), and an offer borrows its Checkpoint, so it
/// cannot outlive the reading that spends it.
///
/// ```
/// fn f(c: &weksa::split::Checkpoint) -> u32 { c.round() }
/// ```
///
/// No public constructor:
///
/// ```compile_fail
/// let _ = weksa::split::Checkpoint::declare(1);
/// ```
///
/// The test constructor is not exported (this doctest names it today; the
/// line-based tripwire in `tests/checkpoint_door.rs` catches only common
/// single-line forms of a new public constructor):
///
/// ```compile_fail
/// let _ = weksa::split::Checkpoint::for_test(1);
/// ```
///
/// ```compile_fail
/// let _ = weksa::split::Checkpoint { round: 1, offered: Default::default() };
/// ```
///
/// One reading per checkpoint:
///
/// ```
/// use weksa::score::TestReadings;
/// use weksa::split::Checkpoint;
/// fn f(c: Checkpoint) { let _ = TestReadings::at_checkpoint(c, &[], &[], &[]); }
/// ```
///
/// ```compile_fail
/// use weksa::score::TestReadings;
/// use weksa::split::Checkpoint;
/// fn f(c: Checkpoint) {
/// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]);
/// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]);
/// }
/// ```
///
/// ```compile_fail
/// fn f(c: weksa::split::Checkpoint) { let _ = c.clone(); }
/// ```
pub struct Checkpoint {
round: u32,
offered: BTreeSet<String>,
}
impl Checkpoint {
#[cfg(test)]
pub(crate) fn for_test(round: u32) -> Self {
Self {
round,
offered: BTreeSet::new(),
}
}
/// Records `key` as offered; false when it already was.
pub(crate) fn first_offer(&mut self, key: &str) -> bool {
self.offered.insert(key.to_string())
}
pub fn round(&self) -> u32 {
self.round
}
}
/// Item builders for the unit tests that live in `src`: the twins of
/// `tests/common`'s `group_in_bucket`, `item_in`, `cast` and `model`.
#[cfg(test)]
pub(crate) mod fixture {
use super::{Split, SplitScheme};
use crate::corpus::{CorpusItem, ModelFamily, Source};
use crate::packet::EntityId;
/// The first group `g<i>` whose V1 bucket is in `split`.
pub(crate) fn group_in(split: Split) -> String {
(0..100_000)
.map(|i| format!("g{i}"))
.find(|g| SplitScheme::V1.of_bucket(SplitScheme::V1.bucket(g)) == split)
.unwrap()
}
pub(crate) fn item(key: &str, text: &str, group: &str, model_written: bool) -> CorpusItem {
let source = if model_written {
Source::ModelWritten {
family: ModelFamily::Unrecorded,
generator: None,
prompt_digest: None,
}
} else {
Source::CastAuthored {
persona: "nibu".into(),
fixture: "f.yaml".into(),
author: "Ada Writer".into(),
}
};
CorpusItem {
id: key.into(),
text: text.into(),
language: "en-US".into(),
speaker: EntityId::parse("entity:nibu").unwrap(),
names: vec![],
group: group.into(),
prior_turns: vec![],
source,
admitted_on: "2026-10-29".into(),
supersedes: None,
withdrawn: false,
}
}
}