diff --git a/Cargo.lock b/Cargo.lock index 72d0e0c..2bc63e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1284,6 +1284,7 @@ dependencies = [ "epiphany-engrave", "epiphany-layout-ir", "epiphany-ops", + "epiphany-textproj", ] [[package]] diff --git a/crates/epiphany-testkit/Cargo.toml b/crates/epiphany-testkit/Cargo.toml index 5d75fc3..212bd02 100644 --- a/crates/epiphany-testkit/Cargo.toml +++ b/crates/epiphany-testkit/Cargo.toml @@ -12,6 +12,7 @@ epiphany-determinism.workspace = true epiphany-core.workspace = true epiphany-bundle.workspace = true epiphany-ops.workspace = true +epiphany-textproj.workspace = true # Agent E's layout IR has landed; the layout round-trip harness (criterion 6) # now drives the real crate instead of an in-tree stub. epiphany-layout-ir.workspace = true diff --git a/crates/epiphany-testkit/examples/conformance_suite.rs b/crates/epiphany-testkit/examples/conformance_suite.rs index fb1cb4e..345f200 100644 --- a/crates/epiphany-testkit/examples/conformance_suite.rs +++ b/crates/epiphany-testkit/examples/conformance_suite.rs @@ -13,7 +13,7 @@ use epiphany_testkit::{ bundle_harness, convergence, corpus, editloop, equivocation, fixtures, generators, layout_stub, - negative, prepass_harness, roundtrip, Rng, + negative, prepass_harness, roundtrip, textproj, Rng, }; fn main() { @@ -173,5 +173,31 @@ fn main() { } } + // 7e. Whole-document Text Projection vectors. The strong equation is over + // text bytes, never bundle identity; the generated-operation pass also + // checks the weaker semantic equation by reducing both sides. + eprintln!("[7e ] Text Projection whole-document conformance vectors"); + for seed in 0..n(16) { + textproj::assert_semantics_preserved(seed); + } + { + use epiphany_textproj::vectors; + assert_eq!( + vectors::COMMITTED, + vectors::render(), + "{} is stale; regenerate with `cargo run -q -p epiphany-testkit \ + --example generate_vectors`", + vectors::PATH + ); + match vectors::verify(vectors::COMMITTED) { + Ok(n) => eprintln!(" {n} vectors, every verdict agreed"), + Err(failures) => panic!( + "{} Text Projection vector disagreement(s):\n{}", + failures.len(), + failures.join("\n") + ), + } + } + eprintln!("[8/8] ok: full conformance suite passed (scale {scale})"); } diff --git a/crates/epiphany-testkit/examples/generate_vectors.rs b/crates/epiphany-testkit/examples/generate_vectors.rs index 70785f8..7f02ee2 100644 --- a/crates/epiphany-testkit/examples/generate_vectors.rs +++ b/crates/epiphany-testkit/examples/generate_vectors.rs @@ -1,9 +1,15 @@ -//! Regenerates `spec/vectors/decode_vectors.txt`, the cross-implementation -//! decode conformance corpus. Run from the workspace root. +//! Regenerates both committed cross-implementation conformance corpora. Run +//! from the workspace root. fn main() { let text = epiphany_testkit::vectors::render(); let path = epiphany_testkit::vectors::PATH; std::fs::write(path, &text).unwrap_or_else(|e| panic!("writing {path}: {e}")); let rows = epiphany_testkit::vectors::parse(&text).expect("parses"); eprintln!("wrote {} vectors to {path}", rows.len()); + + let text = epiphany_textproj::vectors::render(); + let path = epiphany_textproj::vectors::PATH; + std::fs::write(path, &text).unwrap_or_else(|e| panic!("writing {path}: {e}")); + let rows = epiphany_textproj::vectors::parse(&text).expect("parses"); + eprintln!("wrote {} vectors to {path}", rows.len()); } diff --git a/crates/epiphany-testkit/src/lib.rs b/crates/epiphany-testkit/src/lib.rs index a576cc3..dd59493 100644 --- a/crates/epiphany-testkit/src/lib.rs +++ b/crates/epiphany-testkit/src/lib.rs @@ -91,6 +91,7 @@ //! materialization through a bundle snapshot. pub mod rng; +pub mod textproj; pub mod vectors; // Phase 2, Agent F (worklist F1): the Chapter 10 performance-budget gate the diff --git a/crates/epiphany-testkit/src/textproj.rs b/crates/epiphany-testkit/src/textproj.rs new file mode 100644 index 0000000..9e0a0a5 --- /dev/null +++ b/crates/epiphany-testkit/src/textproj.rs @@ -0,0 +1,91 @@ +//! End-to-end semantic preservation for the Text Projection document layer. +//! +//! This is the weaker `req:textproj:roundtrip` equation over generated real +//! operations: +//! +//! `semantics(parse(project(B))) == semantics(B)`. +//! +//! It compares canonical reducer bytes, never bundle bytes. A text round trip +//! intentionally regenerates layout, collapses duplicate blobs, and omits +//! non-canonical accelerators; none of those changes operation semantics. + +use epiphany_bundle::{DocumentId, FileUuid, MemStore, ProfileDeclaration}; +use epiphany_ops::{OperationEnvelope, OperationSet}; +use epiphany_textproj::parse::parse_document; +use epiphany_textproj::project::{document_from_bundle, project_bundle}; +use epiphany_textproj::serialize::serialize_document; +use epiphany_textproj::TextDocument; + +use crate::{generators, Rng}; + +fn reduced_bytes(envelopes: &[OperationEnvelope]) -> Vec { + let mut operations = OperationSet::new(); + operations.accept_all(envelopes.iter().cloned()); + operations.reduce().canonical_bytes() +} + +/// Generates a bundle carrying real operations and checks the semantic +/// projection equation by reducing both envelope sets to canonical bytes. +pub fn assert_semantics_preserved(seed: u64) { + let mut rng = Rng::new(seed); + let envelopes = generators::operation_envelopes(&mut rng, 24, 3, 8, 8); + assert!( + !envelopes.is_empty(), + "the generated bundle carries operations" + ); + + let source = TextDocument { + document_id: DocumentId(seed.to_le_bytes().repeat(2).try_into().expect("16 bytes")), + lineage_id: None, + profiles: vec![ProfileDeclaration::full()], + extensions: Vec::new(), + canonical_base: None, + blobs: Vec::new(), + envelopes, + }; + let bundle = serialize_document(&source, MemStore::new(), FileUuid([0x7E; 16])) + .unwrap_or_else(|error| panic!("seed {seed}: serializing generated operations: {error}")); + + let bundle_document = document_from_bundle(&bundle) + .unwrap_or_else(|error| panic!("seed {seed}: reading source bundle: {error}")); + assert!( + !bundle_document.envelopes.is_empty(), + "seed {seed}: source bundle lost every operation" + ); + let text = project_bundle(&bundle) + .unwrap_or_else(|error| panic!("seed {seed}: projecting source bundle: {error}")); + let parsed = parse_document(&text) + .unwrap_or_else(|error| panic!("seed {seed}: parsing its projection: {error}")); + + // `semantics(B)` must be computed WITHOUT going through the projection. + // Comparing against `bundle_document` alone would not: `project_bundle` is + // `project_text_document(document_from_bundle(..))`, so both sides would flow + // through `document_from_bundle` and any bug in it — dropping an envelope, + // reordering, corrupting one — would cancel out. `source.envelopes` is the + // independent reference: this test built it and serialized it in, and + // `reduced_bytes` reduces through an `OperationSet`, which imposes canonical + // order itself, so the input order does not matter. + assert_eq!( + reduced_bytes(&parsed.envelopes), + reduced_bytes(&source.envelopes), + "seed {seed}: semantics(parse(project(B))) != semantics(B)" + ); + // And the read-back path agrees with the same independent reference. + assert_eq!( + reduced_bytes(&bundle_document.envelopes), + reduced_bytes(&source.envelopes), + "seed {seed}: reading the bundle back changed its semantics" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_operation_bundles_preserve_reduced_semantics_not_bundle_identity() { + for seed in 0..16 { + assert_semantics_preserved(seed); + } + } +} diff --git a/crates/epiphany-textproj/src/lib.rs b/crates/epiphany-textproj/src/lib.rs index 9a5cf7f..c8bfa9e 100644 --- a/crates/epiphany-textproj/src/lib.rs +++ b/crates/epiphany-textproj/src/lib.rs @@ -7,6 +7,7 @@ pub mod parse; pub mod project; pub mod serialize; +pub mod vectors; use epiphany_bundle::{ ChunkKind, DocumentId, ExtensionId, FrontierBytes, LineageId, ProfileDeclaration, ProfileId, diff --git a/crates/epiphany-textproj/src/vectors.rs b/crates/epiphany-textproj/src/vectors.rs new file mode 100644 index 0000000..4e7e66f --- /dev/null +++ b/crates/epiphany-textproj/src/vectors.rs @@ -0,0 +1,686 @@ +//! Whole-document Text Projection conformance vectors. +//! +//! The committed corpus is generated here, inside `epiphany-textproj`, because +//! malformed documents must be constructed without going through the public +//! projection API that canonicalizes outbound data. Each row carries a complete +//! UTF-8 document and a normative accept/reject verdict. +//! +//! The accept contract is the byte-checkable, text-quantified equation +//! +//! `project(serialize(parse(T))) == T`. +//! +//! It is deliberately not bundle identity. Bundle -> text -> bundle collapses +//! duplicate blobs, regenerates physical layout (offsets, compression, and +//! chunking), and drops non-canonical accelerators. Those erasures preserve +//! semantics but make a bundle-byte comparison both false and misleading. + +use std::collections::BTreeMap; + +use epiphany_bundle::{ + ChunkKind, DocumentId, ExtensionId, FileUuid, FrontierBytes, LineageId, MemStore, + ProfileConstraints, ProfileDeclaration, ProfileId, ProfileRegistryId, + ReductionAlgorithmVersion, SchemaVersion, SemVer, SnapshotId, +}; +use epiphany_core::{OperationId, RegionId, ReplicaId, WallClockTime}; +use epiphany_ops::{ + AuthorId, CausalContext, DeleteRegionOp, HybridLogicalClock, OperationEnvelope, OperationKind, + OperationPayload, OperationStamp, +}; + +use crate::parse::parse_document; +use crate::project::{project_bundle, project_text_document}; +use crate::serialize::serialize_document; +use crate::{TextCanonicalBase, TextChunk, TextDocument, TextExtension}; + +/// The corpus surface owned by this crate. +pub const SURFACE: &str = "textproj.document"; +/// The committed whole-document corpus. +pub const COMMITTED: &str = include_str!("../../../spec/vectors/textproj_document_vectors.txt"); +/// The path regenerated by the vector generator, relative to the workspace root. +pub const PATH: &str = "spec/vectors/textproj_document_vectors.txt"; + +const HEADER: &str = "\ +# Epiphany Text Projection document vectors — format version 1 +# +# Generated. Regenerate with: +# cargo run -q -p epiphany-testkit --example generate_vectors +# `epiphany_textproj::vectors::the_committed_corpus_matches_the_generator` fails +# on drift, so a projection change must land here deliberately. +# +# One vector per line, space-separated: +# +# +# +# NORMATIVE: Text Projection companion, `req:textproj:roundtrip` and +# `req:textproj:strict-parse`. Every `accept` text must satisfy the exact-byte +# equation project(serialize(parse(T))) == T. Every `reject` text must be +# refused, not accepted and normalized to a different canonical spelling. +# +# The equation quantifies over TEXTS, not bundle bytes. Binary layout is freely +# regenerated, duplicate blobs collapse, and non-canonical accelerators vanish; +# all three preserve semantics and none weakens this text identity check. +# +# `class` names the rejection exercised. It is informative; the verdict and +# document bytes are normative. `` is lowercase with no separators. +"; + +/// One generated vector: `(surface, verdict, class, name, UTF-8 bytes)`. +pub type TextVector = ( + &'static str, + &'static str, + &'static str, + &'static str, + Vec, +); + +/// The typed outcome of checking one whole document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CheckVerdict { + /// The parser refused the text. + Rejected(String), + /// The parser accepted it and the byte-checkable equation held exactly. + AcceptedCanonical, + /// The parser accepted it, but serialization/projection either failed or + /// produced different bytes. This is acceptance, never rejection. + AcceptedNonCanonical(String), +} + +/// One parsed row from the committed text file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + /// Owning conformance surface. + pub surface: String, + /// Normative `accept` or `reject` verdict. + pub verdict: String, + /// Informative rejection class, or `-` for accepts. + pub class: String, + /// Stable vector name. + pub name: String, + /// Complete UTF-8 document bytes. + pub text: Vec, +} + +/// Coverage counts asserted by the corpus gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReachCounts { + /// Number of accepted documents containing at least one extension. + pub extensions: usize, + /// Number containing a canonical base. + pub canonical_bases: usize, + /// Number declaring a custom profile. + pub custom_profiles: usize, + /// Number carrying a lineage id. + pub lineages: usize, + /// Number carrying more than one envelope. + pub multi_envelope: usize, + /// Actually rejected vectors per declared rejection class. + pub reject_classes: BTreeMap, +} + +/// The exact non-vacuity contract of this corpus: four accepted documents, two +/// reaching each optional/rich feature, and one actually rejected vector for +/// each of the nine distinct rejection classes implemented by this layer. +pub fn expected_reach() -> ReachCounts { + ReachCounts { + extensions: 2, + canonical_bases: 2, + custom_profiles: 2, + lineages: 2, + multi_envelope: 2, + reject_classes: [ + ("blob-line", 1), + ("extension-chunk-order", 1), + ("extension-declaration-order", 1), + ("missing-trailing-lf", 1), + ("operation-envelope-order", 1), + ("out-of-order-sections", 1), + ("profile-declaration-order", 1), + ("repeated-singular-section", 1), + ("wrong-header-version", 1), + ] + .into_iter() + .map(|(class, count)| (class.to_owned(), count)) + .collect(), + } +} + +fn sample_envelope(counter: u64, physical_time: i64) -> OperationEnvelope { + let id = OperationId::new(ReplicaId(1), counter); + OperationEnvelope { + id, + author: AuthorId(0xAB), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical_time), 0), id), + causal_context: CausalContext::new(), + transaction: None, + payload: OperationPayload::Primitive(OperationKind::DeleteRegion(DeleteRegionOp { + region: RegionId::new(ReplicaId(1), counter), + })), + } +} + +fn profiles(custom: bool) -> Vec { + let mut profiles = vec![ProfileDeclaration::full()]; + if custom { + profiles.push(ProfileDeclaration { + profile_id: ProfileId::Custom(ProfileRegistryId([0xCC; 16])), + version: SemVer::new(1, 2, 3), + constraints: ProfileConstraints::DEFAULT_FULL, + }); + } + profiles +} + +fn extension(id_byte: u8, payloads: &[u8]) -> TextExtension { + TextExtension { + extension_id: ExtensionId([id_byte; 16]), + version: SemVer::new(1, 0, u32::from(id_byte)), + required: false, + chunks: payloads + .iter() + .map(|byte| TextChunk { + kind: ChunkKind::ExtensionData, + schema_version: SchemaVersion::V0, + payload: vec![*byte], + }) + .collect(), + affected_object_kinds: vec![id_byte], + edit_barriers: vec![id_byte, id_byte.wrapping_add(1)], + } +} + +fn base(snapshot_byte: u8) -> TextCanonicalBase { + TextCanonicalBase { + snapshot_id: SnapshotId([snapshot_byte; 16]), + covers_causal_frontier: FrontierBytes::from_bytes(vec![1, 2, snapshot_byte]), + reduction_algorithm_version: ReductionAlgorithmVersion(1), + profile_id: ProfileId::Full, + root_schema_version: SchemaVersion::V0, + root_payload: vec![0xA0, snapshot_byte], + } +} + +fn accept_documents() -> Vec<(&'static str, String)> { + let minimal = TextDocument { + document_id: DocumentId([1; 16]), + lineage_id: None, + profiles: profiles(false), + extensions: Vec::new(), + canonical_base: None, + blobs: Vec::new(), + envelopes: Vec::new(), + }; + let lineage_custom = TextDocument { + document_id: DocumentId([2; 16]), + lineage_id: Some(LineageId([0x12; 16])), + profiles: profiles(true), + extensions: Vec::new(), + canonical_base: None, + blobs: Vec::new(), + envelopes: vec![sample_envelope(1, 100)], + }; + let extension_base_multi = TextDocument { + document_id: DocumentId([3; 16]), + lineage_id: None, + profiles: profiles(false), + extensions: vec![extension(1, &[1, 2])], + canonical_base: Some(base(3)), + blobs: Vec::new(), + envelopes: vec![sample_envelope(2, 200), sample_envelope(3, 300)], + }; + let rich = TextDocument { + document_id: DocumentId([4; 16]), + lineage_id: Some(LineageId([0x14; 16])), + profiles: profiles(true), + extensions: vec![extension(1, &[1, 2]), extension(2, &[3])], + canonical_base: Some(base(4)), + blobs: Vec::new(), + envelopes: vec![ + sample_envelope(4, 400), + sample_envelope(5, 500), + sample_envelope(6, 600), + ], + }; + + vec![ + ("minimal", project_text_document(&minimal)), + ( + "lineage_custom_profile", + project_text_document(&lineage_custom), + ), + ( + "extension_base_two_envelopes", + project_text_document(&extension_base_multi), + ), + ("rich_document", project_text_document(&rich)), + ] +} + +fn replace_once(text: &str, anchor: &str, replacement: &str) -> String { + assert!( + text.contains(anchor), + "vector-construction anchor is absent: {anchor}" + ); + text.replacen(anchor, replacement, 1) +} + +fn lines_with_final_lf(lines: &[String]) -> String { + let mut text = lines.join("\n"); + text.push('\n'); + text +} + +fn swap_first_lines(text: &str, first_head: &str, second_head: &str) -> String { + let mut lines: Vec = text.lines().map(str::to_owned).collect(); + let first = lines + .iter() + .position(|line| line.starts_with(first_head)) + .unwrap_or_else(|| panic!("vector-construction line is absent: {first_head}")); + let second = lines + .iter() + .position(|line| line.starts_with(second_head)) + .unwrap_or_else(|| panic!("vector-construction line is absent: {second_head}")); + lines.swap(first, second); + lines_with_final_lf(&lines) +} + +fn swap_first_two_lines(text: &str, head: &str) -> String { + let mut lines: Vec = text.lines().map(str::to_owned).collect(); + let matches: Vec = lines + .iter() + .enumerate() + .filter_map(|(index, line)| line.starts_with(head).then_some(index)) + .collect(); + assert!( + matches.len() >= 2, + "vector construction needs two `{head}` lines" + ); + lines.swap(matches[0], matches[1]); + lines_with_final_lf(&lines) +} + +fn duplicate_first_line(text: &str, head: &str) -> String { + let mut lines: Vec = text.lines().map(str::to_owned).collect(); + let at = lines + .iter() + .position(|line| line.starts_with(head)) + .unwrap_or_else(|| panic!("vector-construction line is absent: {head}")); + let duplicate = lines[at].clone(); + lines.insert(at + 1, duplicate); + lines_with_final_lf(&lines) +} + +/// Every whole-document vector in stable committed order. +pub fn document_vectors() -> Vec { + let accepts = accept_documents(); + let minimal = &accepts[0].1; + let lineage_custom = &accepts[1].1; + let extension_base_multi = &accepts[2].1; + let rich = &accepts[3].1; + + let mut vectors: Vec = accepts + .iter() + .map(|(name, text)| (SURFACE, "accept", "-", *name, text.as_bytes().to_vec())) + .collect(); + + let wrong_version = replace_once( + minimal, + "(text-projection (0 7 0))", + "(text-projection (0 8 0))", + ); + vectors.push(( + SURFACE, + "reject", + "wrong-header-version", + "future_companion_version", + wrong_version.into_bytes(), + )); + + let blob = replace_once( + minimal, + "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))\n", + "(profile full (0 1 0) (constraints 67108864 (retention 1 () true)))\n\ + (blob \"application/octet-stream\" () #x01)\n", + ); + vectors.push(( + SURFACE, + "reject", + "blob-line", + "unreferenced_blob", + blob.into_bytes(), + )); + + vectors.push(( + SURFACE, + "reject", + "out-of-order-sections", + "canonical_base_before_extension", + swap_first_lines(extension_base_multi, "(extension ", "(canonical-base ").into_bytes(), + )); + + vectors.push(( + SURFACE, + "reject", + "repeated-singular-section", + "lineage_repeated", + duplicate_first_line(lineage_custom, "(lineage ").into_bytes(), + )); + + vectors.push(( + SURFACE, + "reject", + "operation-envelope-order", + "envelopes_reversed", + swap_first_two_lines(extension_base_multi, "(envelope ").into_bytes(), + )); + + vectors.push(( + SURFACE, + "reject", + "profile-declaration-order", + "profiles_reversed", + swap_first_two_lines(lineage_custom, "(profile ").into_bytes(), + )); + + vectors.push(( + SURFACE, + "reject", + "extension-declaration-order", + "extensions_reversed", + swap_first_two_lines(rich, "(extension ").into_bytes(), + )); + + let chunks_reversed = replace_once( + extension_base_multi, + "((chunk extension-data (schema 0 1) #x01) (chunk extension-data (schema 0 1) #x02))", + "((chunk extension-data (schema 0 1) #x02) (chunk extension-data (schema 0 1) #x01))", + ); + vectors.push(( + SURFACE, + "reject", + "extension-chunk-order", + "extension_chunks_reversed", + chunks_reversed.into_bytes(), + )); + + let mut missing_lf = minimal.as_bytes().to_vec(); + assert_eq!(missing_lf.pop(), Some(b'\n'), "accept text ends in LF"); + vectors.push(( + SURFACE, + "reject", + "missing-trailing-lf", + "final_lf_missing", + missing_lf, + )); + + vectors +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn from_hex(hex: &str) -> Option> { + if hex.len() % 2 != 0 { + return None; + } + (0..hex.len()) + .step_by(2) + .map(|at| u8::from_str_radix(&hex[at..at + 2], 16).ok()) + .collect() +} + +/// Renders the drift-locked corpus file. +pub fn render() -> String { + let mut out = String::from(HEADER); + out.push_str("\n# textproj.document\n"); + for (surface, verdict, class, name, text) in document_vectors() { + out.push_str(&format!( + "{surface} {verdict} {class} {name} {}\n", + to_hex(&text) + )); + } + out +} + +/// Parses a corpus file, skipping comments and blank lines. +pub fn parse(text: &str) -> Result, String> { + let mut rows = Vec::new(); + for (index, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() != 5 { + return Err(format!( + "line {}: expected 5 columns, got {}", + index + 1, + fields.len() + )); + } + rows.push(Row { + surface: fields[0].to_owned(), + verdict: fields[1].to_owned(), + class: fields[2].to_owned(), + name: fields[3].to_owned(), + text: from_hex(fields[4]).ok_or_else(|| format!("line {}: bad hex", index + 1))?, + }); + } + Ok(rows) +} + +/// Checks one corpus surface without collapsing parser rejection with accepted +/// normalization. `None` means this crate does not own `surface`. +pub fn check(surface: &str, text: &[u8]) -> Option { + if surface != SURFACE { + return None; + } + let text = match std::str::from_utf8(text) { + Ok(text) => text, + Err(error) => return Some(CheckVerdict::Rejected(format!("non-UTF-8 text: {error}"))), + }; + let document = match parse_document(text) { + Ok(document) => document, + Err(error) => return Some(CheckVerdict::Rejected(error.to_string())), + }; + let bundle = match serialize_document(&document, MemStore::new(), FileUuid([0x7E; 16])) { + Ok(bundle) => bundle, + Err(error) => { + return Some(CheckVerdict::AcceptedNonCanonical(format!( + "parse accepted, but serialization failed: {error}" + ))) + } + }; + let projected = match project_bundle(&bundle) { + Ok(projected) => projected, + Err(error) => { + return Some(CheckVerdict::AcceptedNonCanonical(format!( + "parse accepted, but re-projection failed: {error}" + ))) + } + }; + if projected.as_bytes() == text.as_bytes() { + Some(CheckVerdict::AcceptedCanonical) + } else { + Some(CheckVerdict::AcceptedNonCanonical( + "parse accepted text that re-projected to different bytes".to_owned(), + )) + } +} + +fn verify_rows(rows: &[Row]) -> Vec { + let mut failures = Vec::new(); + for row in rows { + let Some(actual) = check(&row.surface, &row.text) else { + failures.push(format!("{}: no checker owns this surface", row.surface)); + continue; + }; + match (row.verdict.as_str(), actual) { + ("accept", CheckVerdict::AcceptedCanonical) | ("reject", CheckVerdict::Rejected(_)) => { + } + ("accept", CheckVerdict::AcceptedNonCanonical(detail)) => failures.push(format!( + "{}/{}: accepted, but not canonical: {detail}", + row.surface, row.name + )), + ("reject", CheckVerdict::AcceptedNonCanonical(detail)) => failures.push(format!( + "{}/{} ({}): declared reject, but was ACCEPTED and normalized: {detail}", + row.surface, row.name, row.class + )), + ("reject", CheckVerdict::AcceptedCanonical) => failures.push(format!( + "{}/{} ({}): declared reject, but was ACCEPTED as canonical", + row.surface, row.name, row.class + )), + ("accept", CheckVerdict::Rejected(error)) => failures.push(format!( + "{}/{}: declared accept, but was rejected: {error}", + row.surface, row.name + )), + (other, actual) => failures.push(format!( + "{}/{}: unknown verdict {other}, got {actual:?}", + row.surface, row.name + )), + } + } + failures +} + +/// Measures the corpus's actual reach. Reject counts include only vectors the +/// parser really refused; an accidentally canonicalized reject therefore drops +/// its class to zero instead of receiving credit from its label. +pub fn reach(text: &str) -> Result> { + let rows = parse(text).map_err(|error| vec![error])?; + let mut reach = ReachCounts { + extensions: 0, + canonical_bases: 0, + custom_profiles: 0, + lineages: 0, + multi_envelope: 0, + reject_classes: BTreeMap::new(), + }; + let mut failures = Vec::new(); + + for row in &rows { + match row.verdict.as_str() { + "accept" => { + let text = match std::str::from_utf8(&row.text) { + Ok(text) => text, + Err(error) => { + failures.push(format!("{}/{}: {error}", row.surface, row.name)); + continue; + } + }; + let document = match parse_document(text) { + Ok(document) => document, + Err(error) => { + failures.push(format!("{}/{}: {error}", row.surface, row.name)); + continue; + } + }; + reach.extensions += usize::from(!document.extensions.is_empty()); + reach.canonical_bases += usize::from(document.canonical_base.is_some()); + reach.custom_profiles += usize::from( + document + .profiles + .iter() + .any(|profile| matches!(profile.profile_id, ProfileId::Custom(_))), + ); + reach.lineages += usize::from(document.lineage_id.is_some()); + reach.multi_envelope += usize::from(document.envelopes.len() > 1); + } + "reject" => { + if matches!( + check(&row.surface, &row.text), + Some(CheckVerdict::Rejected(_)) + ) { + *reach.reject_classes.entry(row.class.clone()).or_default() += 1; + } + } + other => failures.push(format!( + "{}/{}: unknown verdict {other}", + row.surface, row.name + )), + } + } + + if failures.is_empty() { + Ok(reach) + } else { + Err(failures) + } +} + +/// Verifies every verdict and the exact non-vacuity reach contract, returning +/// the number of checked whole documents or all disagreements. +pub fn verify(text: &str) -> Result> { + let rows = parse(text).map_err(|error| vec![error])?; + let mut failures = verify_rows(&rows); + match reach(text) { + Ok(actual) if actual == expected_reach() => {} + Ok(actual) => failures.push(format!( + "corpus reach mismatch: expected {:?}, got {actual:?}", + expected_reach() + )), + Err(mut errors) => failures.append(&mut errors), + } + if failures.is_empty() { + Ok(rows.len()) + } else { + Err(failures) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_committed_corpus_matches_the_generator() { + assert_eq!( + COMMITTED, + render(), + "\n{PATH} is stale. Regenerate:\n \ + cargo run -q -p epiphany-testkit --example generate_vectors\n" + ); + } + + #[test] + fn the_reference_implementation_agrees_with_every_vector() { + match verify(COMMITTED) { + Ok(count) => assert_eq!(count, 13, "the corpus has unexpectedly thinned"), + Err(failures) => panic!( + "{} disagreement(s):\n{}", + failures.len(), + failures.join("\n") + ), + } + } + + /// This test intentionally names TEXT identity. Comparing either bundle's + /// bytes would be wrong: duplicate blobs collapse, layout is regenerated, + /// and non-canonical accelerators vanish while semantics remain unchanged. + #[test] + fn every_accept_vector_satisfies_the_text_quantified_byte_equation() { + for row in parse(COMMITTED) + .expect("the committed corpus parses") + .into_iter() + .filter(|row| row.verdict == "accept") + { + assert_eq!( + check(&row.surface, &row.text), + Some(CheckVerdict::AcceptedCanonical), + "{}/{} does not satisfy project(serialize(parse(T))) == T", + row.surface, + row.name + ); + } + } + + #[test] + fn the_corpus_reach_is_exact_and_non_vacuous() { + assert_eq!( + reach(COMMITTED).expect("the committed corpus has measurable reach"), + expected_reach(), + "the corpus must reach 2 extensions, 2 canonical bases, 2 custom profiles, \ + 2 lineages, 2 multi-envelope documents, and one real rejection in each class" + ); + } +} diff --git a/spec/vectors/textproj_document_vectors.txt b/spec/vectors/textproj_document_vectors.txt new file mode 100644 index 0000000..4cbf39b --- /dev/null +++ b/spec/vectors/textproj_document_vectors.txt @@ -0,0 +1,37 @@ +# Epiphany Text Projection document vectors — format version 1 +# +# Generated. Regenerate with: +# cargo run -q -p epiphany-testkit --example generate_vectors +# `epiphany_textproj::vectors::the_committed_corpus_matches_the_generator` fails +# on drift, so a projection change must land here deliberately. +# +# One vector per line, space-separated: +# +# +# +# NORMATIVE: Text Projection companion, `req:textproj:roundtrip` and +# `req:textproj:strict-parse`. Every `accept` text must satisfy the exact-byte +# equation project(serialize(parse(T))) == T. Every `reject` text must be +# refused, not accepted and normalized to a different canonical spelling. +# +# The equation quantifies over TEXTS, not bundle bytes. Binary layout is freely +# regenerated, duplicate blobs collapse, and non-canonical accelerators vanish; +# all three preserve semantics and none weakens this text identity check. +# +# `class` names the rejection exercised. It is informative; the verdict and +# document bytes are normative. `` is lowercase with no separators. + +# textproj.document +textproj.document accept - minimal 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783031303130313031303130313031303130313031303130313031303130313031290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a +textproj.document accept - lineage_custom_profile 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783032303230323032303230323032303230323032303230323032303230323032290a286c696e656167652023783132313231323132313231323132313231323132313231323132313231323132290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a2870726f66696c652028637573746f6d20237863636363636363636363636363636363636363636363636363636363636363632920283120322033292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303031202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203130302030202378303030303030303030303030303030313030303030303030303030303030303129202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030312929290a +textproj.document accept - extension_base_two_envelopes 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783033303330333033303330333033303330333033303330333033303330333033290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28657874656e73696f6e202378303130313031303130313031303130313031303130313031303130313031303120283120302031292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830312920286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830322929202378303120237830313032290a2863616e6f6e6963616c2d62617365202378303330333033303330333033303330333033303330333033303330333033303320237830313032303320312066756c6c2028736368656d61203020312920237861303033290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303032202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203230302030202378303030303030303030303030303030313030303030303030303030303030303229202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030322929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303033202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203330302030202378303030303030303030303030303030313030303030303030303030303030303329202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030332929290a +textproj.document accept - rich_document 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783034303430343034303430343034303430343034303430343034303430343034290a286c696e656167652023783134313431343134313431343134313431343134313431343134313431343134290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a2870726f66696c652028637573746f6d20237863636363636363636363636363636363636363636363636363636363636363632920283120322033292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28657874656e73696f6e202378303130313031303130313031303130313031303130313031303130313031303120283120302031292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830312920286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830322929202378303120237830313032290a28657874656e73696f6e202378303230323032303230323032303230323032303230323032303230323032303220283120302032292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830332929202378303220237830323033290a2863616e6f6e6963616c2d62617365202378303430343034303430343034303430343034303430343034303430343034303420237830313032303420312066756c6c2028736368656d61203020312920237861303034290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303034202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203430302030202378303030303030303030303030303030313030303030303030303030303030303429202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030342929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303035202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203530302030202378303030303030303030303030303030313030303030303030303030303030303529202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030352929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303036202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203630302030202378303030303030303030303030303030313030303030303030303030303030303629202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030362929290a +textproj.document reject wrong-header-version future_companion_version 28746578742d70726f6a656374696f6e2028302038203029290a28646f63756d656e742023783031303130313031303130313031303130313031303130313031303130313031290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a +textproj.document reject blob-line unreferenced_blob 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783031303130313031303130313031303130313031303130313031303130313031290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28626c6f6220226170706c69636174696f6e2f6f637465742d73747265616d222028292023783031290a +textproj.document reject out-of-order-sections canonical_base_before_extension 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783033303330333033303330333033303330333033303330333033303330333033290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a2863616e6f6e6963616c2d62617365202378303330333033303330333033303330333033303330333033303330333033303320237830313032303320312066756c6c2028736368656d61203020312920237861303033290a28657874656e73696f6e202378303130313031303130313031303130313031303130313031303130313031303120283120302031292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830312920286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830322929202378303120237830313032290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303032202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203230302030202378303030303030303030303030303030313030303030303030303030303030303229202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030322929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303033202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203330302030202378303030303030303030303030303030313030303030303030303030303030303329202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030332929290a +textproj.document reject repeated-singular-section lineage_repeated 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783032303230323032303230323032303230323032303230323032303230323032290a286c696e656167652023783132313231323132313231323132313231323132313231323132313231323132290a286c696e656167652023783132313231323132313231323132313231323132313231323132313231323132290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a2870726f66696c652028637573746f6d20237863636363636363636363636363636363636363636363636363636363636363632920283120322033292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303031202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203130302030202378303030303030303030303030303030313030303030303030303030303030303129202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030312929290a +textproj.document reject operation-envelope-order envelopes_reversed 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783033303330333033303330333033303330333033303330333033303330333033290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28657874656e73696f6e202378303130313031303130313031303130313031303130313031303130313031303120283120302031292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830312920286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830322929202378303120237830313032290a2863616e6f6e6963616c2d62617365202378303330333033303330333033303330333033303330333033303330333033303320237830313032303320312066756c6c2028736368656d61203020312920237861303033290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303033202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203330302030202378303030303030303030303030303030313030303030303030303030303030303329202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030332929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303032202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203230302030202378303030303030303030303030303030313030303030303030303030303030303229202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030322929290a +textproj.document reject profile-declaration-order profiles_reversed 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783032303230323032303230323032303230323032303230323032303230323032290a286c696e656167652023783132313231323132313231323132313231323132313231323132313231323132290a2870726f66696c652028637573746f6d20237863636363636363636363636363636363636363636363636363636363636363632920283120322033292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303031202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203130302030202378303030303030303030303030303030313030303030303030303030303030303129202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030312929290a +textproj.document reject extension-declaration-order extensions_reversed 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783034303430343034303430343034303430343034303430343034303430343034290a286c696e656167652023783134313431343134313431343134313431343134313431343134313431343134290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a2870726f66696c652028637573746f6d20237863636363636363636363636363636363636363636363636363636363636363632920283120322033292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28657874656e73696f6e202378303230323032303230323032303230323032303230323032303230323032303220283120302032292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830332929202378303220237830323033290a28657874656e73696f6e202378303130313031303130313031303130313031303130313031303130313031303120283120302031292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830312920286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830322929202378303120237830313032290a2863616e6f6e6963616c2d62617365202378303430343034303430343034303430343034303430343034303430343034303420237830313032303420312066756c6c2028736368656d61203020312920237861303034290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303034202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203430302030202378303030303030303030303030303030313030303030303030303030303030303429202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030342929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303035202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203530302030202378303030303030303030303030303030313030303030303030303030303030303529202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030352929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303036202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203630302030202378303030303030303030303030303030313030303030303030303030303030303629202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030362929290a +textproj.document reject extension-chunk-order extension_chunks_reversed 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783033303330333033303330333033303330333033303330333033303330333033290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e203120282920747275652929290a28657874656e73696f6e202378303130313031303130313031303130313031303130313031303130313031303120283120302031292066616c73652028286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830322920286368756e6b20657874656e73696f6e2d646174612028736368656d61203020312920237830312929202378303120237830313032290a2863616e6f6e6963616c2d62617365202378303330333033303330333033303330333033303330333033303330333033303320237830313032303320312066756c6c2028736368656d61203020312920237861303033290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303032202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203230302030202378303030303030303030303030303030313030303030303030303030303030303229202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030322929290a28656e76656c6f70652023783030303030303030303030303030303130303030303030303030303030303033202378303030303030303030303030303030303030303030303030303030303030616220287374616d70203330302030202378303030303030303030303030303030313030303030303030303030303030303329202863617573616c2028292028292920282920287072696d6974697665202864656c6574652d726567696f6e20237830303030303030303030303030303031303030303030303030303030303030332929290a +textproj.document reject missing-trailing-lf final_lf_missing 28746578742d70726f6a656374696f6e2028302037203029290a28646f63756d656e742023783031303130313031303130313031303130313031303130313031303130313031290a2870726f66696c652066756c6c20283020312030292028636f6e73747261696e74732036373130383836342028726574656e74696f6e20312028292074727565292929