diff --git a/README.md b/README.md index e942ff6..1eb2c3e 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,10 @@ edits `.cc` state. Huginn reads and writes minds. A Cargo workspace of three crates: - `crates/huginn-mind`: storage, identity, and admission of memory documents. -- `crates/huginn-daemon`: the CultNet surface and serve loop. -- `crates/eureka-state`: typed state for the Eureka pipeline. +- `crates/huginn-daemon`: a stub. It will carry the CultNet surface and serve + loop; today it holds neither. +- `crates/eureka-state`: a stub. It will carry typed state for the Eureka + pipeline; today it holds none. `huginn-mind` is live: it opens one instance's store (an owned redb CultCache at `/minds//mind.redb`, locked for the mind's lifetime), diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 1a2dd5d..a0865be 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -1082,6 +1082,118 @@ mod tests { ); } + /// Q17 B and Q19 A together: withdrawing a resolution reopens its subject + /// without erasing the record. The withdrawn closure is still readable by + /// key, the subject takes its next resolution at the next sequence, and + /// the chain stops at depth two -- a withdrawal cannot be withdrawn. + #[test] + fn a_withdrawn_resolution_reopens_its_subject_and_stays_readable() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + let first = id("resolution", "question.Q1.n1"); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + + // The withdrawal of that closure: one nesting out, under the + // resolution it withdraws. + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + let withdrawal = id("resolution", "resolution.question.Q1.n1.n1"); + assert!(mind.get(K::Resolution, &withdrawal).unwrap().is_some()); + + // The subject is open again, and its next record is `n2`. The first + // one is still there, outcome and all: this is a log, not an erasure. + committed(admit(&mut mind, vec![resolution_n(subject.clone(), 2, withdrawn())])); + let Some(D::Resolution(kept)) = mind.get(K::Resolution, &first).unwrap() else { panic!() }; + assert_eq!(kept.outcome, withdrawn()); + assert_eq!(kept.sequence, 1); + + // `n2` stands, so a third is refused, and the withdrawal cannot + // itself be withdrawn to re-raise `n1` over it (Q19 A). + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(subject, 3, withdrawn())])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), + MindRefusal::IncompatibleResolution { subject_kind: K::Resolution, outcome: "Withdrawn".into() } + ); + + // And a ruling may answer a question whose first closure was + // withdrawn: the derived resolution takes the next sequence. + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let (_, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!(writes, vec![ + r(K::Ruling, &id("ruling", "R1")), + r(K::Resolution, &id("resolution", "question.Q1.n2")), + ]); + } + + /// Q18 A and Q20 A at admission: a repo is stewarded by one record at a + /// time on a mind, and a repo handed away and handed back is a second + /// record rather than a collision or an overwrite. + #[test] + fn a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer() { + let mut mind = seeded(); + let first = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 2)])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + let D::Stewardship(mut retaken) = stewardship(INSTANCE, REPO) else { panic!() }; + retaken.note = "assigned again".into(); + assert_eq!( + refusal(admit(&mut mind, vec![D::Stewardship(retaken)])), + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 2, actual: 1 } + ); + + // Handing the repo away withdraws the assignment, and the withdrawal + // is a resolution of that record, sequence and all. The stewardship it + // reads to derive that is an image document, so it is pinned. + let (receipt_id, writes) = committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let away = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.{}", date().0); + assert_eq!(writes, vec![ + r(K::HandOff, &away), + r(K::Resolution, &format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1")), + ]); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + let stewardship_envelope = mind.envelope(K::Stewardship, &first).unwrap().clone(); + assert!( + receipt.strong_reads.contains(&DocumentVersion::from_envelope(&stewardship_envelope)), + "the derived withdrawal's own citation is pinned: {:?}", + receipt.strong_reads + ); + + // Nothing stewards the repo now, and `stewardship_of` says so: a new + // campaign over it is refused. + let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; + second.slug = slug("second"); + assert_eq!( + refusal(admit(&mut mind, vec![D::Campaign(second)])), + MindRefusal::RepoNotStewarded { repo: REPO.into() } + ); + + // Handed back, the mind stewards it again, as `n2`. No field names a + // return: this is the same hand-off shape with the instances swapped. + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + let (_, writes) = committed(admit(&mut mind, vec![D::HandOff(back)])); + let again = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2"); + assert_eq!(writes, vec![ + r(K::HandOff, &format!("{OTHER_INSTANCE}:hand_off:{INSTANCE}.GameCult_-Epiphany.2026-09-17")), + r(K::Stewardship, &again), + ]); + let Some(D::Stewardship(taken)) = mind.get(K::Stewardship, &again).unwrap() else { panic!() }; + assert_eq!(taken.sequence, 2); + assert_eq!(taken.assigned_on, epiphany_pipeline::Date("2026-09-17".into())); + assert!(mind.envelope(K::Stewardship, &first).is_some(), "the first assignment is still readable by key"); + } + /// A seeded mind with one document of every resolvable kind, and the /// non-resolvable ones beside them. fn world() -> Mind { @@ -1238,7 +1350,15 @@ mod tests { answering.choice = Some(l("A")); let wrong = resolution(r(K::Question, &id("question", "Q2")), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); assert_eq!( - refusal(admit(&mut mind, vec![D::Ruling(answering), wrong])), + refusal(admit(&mut mind, vec![D::Ruling(answering.clone()), wrong.clone()])), + MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } + ); + // The coherence is checked whether the ruling is in the batch or in + // the image: a later batch citing the stored R1 is refused the same + // way, and the derived resolution of Q1 is not what saves it. + committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!( + refusal(admit(&mut mind, vec![wrong])), MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } ); } @@ -1306,6 +1426,17 @@ mod tests { for envelope in &cited { assert!(receipt.strong_reads.contains(&DocumentVersion::from_envelope(envelope)), "{}", envelope.key); } + + // Two batch documents citing one image document pin it once: the pins + // are a set, so a second citation neither duplicates the pin nor + // cancels it. + let mut twice = cut_spec("2", 1); + twice.rulings = vec![s(&id("ruling", "R1"))]; + let D::Question(mut raised) = question("Q1", &["A", "B"], "A") else { panic!() }; + raised.raised_in = Some(r(K::Ruling, &id("ruling", "R1"))); + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(twice), D::Question(raised)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads, vec![DocumentVersion::from_envelope(&cited[0])], "cited twice, pinned once"); } #[test] @@ -1351,6 +1482,11 @@ mod tests { let mut report = cut_report("1", 1); report.branch = s("another-branch"); assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); + // A branch is compared as written. Git's refs are case-sensitive, and + // a report of `Codex/...` is a report of another branch or of none. + let mut report = cut_report("1", 1); + report.branch = s("CODEX/eureka-pipeline-state"); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); let mut report = cut_report("1", 1); report.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "repo".into() }); @@ -1428,6 +1564,7 @@ mod tests { unknown.invariants = vec![l("nope")]; assert_eq!(refusal(admit(&mut mind, vec![D::Finding(unknown)])), MindRefusal::UnknownInvariant { label: "nope".into() }); + // A raw envelope without `range` cannot be built from the type, and // it is the leaf's decode that refuses it, before any organ rule. #[derive(serde::Serialize)] @@ -1469,10 +1606,20 @@ mod tests { // The in-force target owns the invariant vocabulary. Supersede r1 with // an r2 that drops its label, and the label is unknown again; r2's own - // label is what a finding may cite. + // label is what a finding may cite. The target is looked for in image + // and batch, like every other citation, so a finding may cite the + // vocabulary of the revision landing beside it: `other` is admitted in + // r2's own batch, before r2 is anywhere but here. + let mut fresh = finding("1", 1, "F4", FindingConfidence::Plausible); + fresh.invariants = vec![l("other")]; + assert_eq!( + refusal(admit(&mut mind, vec![D::Finding(fresh.clone())])), + MindRefusal::UnknownInvariant { label: "other".into() } + ); committed(admit(&mut mind, vec![ target(2, &["other"]), resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + D::Finding(fresh), ])); assert_eq!( refusal(admit(&mut mind, vec![D::Finding(finding("1", 1, "F2", FindingConfidence::Plausible))])), diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index 9086272..54856d2 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -1,6 +1,15 @@ -# Cut 8 mutations, H1-H39: each entry restores one permissiveness the cut -# closed and names the test that must fail while it is applied. Run through -# Epiphany's harness from this repo: +# Cut 8 mutations, H1-H51: each entry restores one permissiveness the cut +# closed and names the test that must fail while it is applied. H40-H46 are +# the Cut 6d follow-up's rules (the two sequences, the recursive in-force, the +# single in-force stewardship, the Q19 cap, and the sequences `derive` sets); +# H47-H51 close the gaps Soul found where the code was right and the suite +# blind. Run through Epiphany's harness from this repo: +# +# H32 is re-anchored to the same follow-up: its A4 half is unreachable now +# that a derived write takes the sequence after the batch's own records, so a +# derived identity can no longer collide with one the batch holds. What it +# still pins is that derived writes are validated, staged and resolved like +# any other, which the transfer test's strong read shows. # # $env:CARGO_TARGET_DIR = 'C:\Users\Meta\.cargo-target-codex' # powershell -File F:\Projects\Epiphany\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` @@ -397,8 +406,8 @@ impl MindStore for OwnedRedbMessagePackBackingStore { } @{ Id = 'H32' - Rule = 'Derived writes pass A3-A7 like any other; a derived identity the batch already holds is A4''s refusal, not a store error.' - Test = 'admission::tests::a_hand_off_derives_this_minds_side_only' + Rule = 'Derived writes go through validation, A4 and A7 like any other: what a derived write cites in the image is pinned.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' File = 'crates/huginn-mind/src/admission.rs' Old = @' for document in derive(&docs, &mind) { @@ -486,5 +495,144 @@ impl MindStore for OwnedRedbMessagePackBackingStore { '@ New = ' let _ = (ruling, subject_id);' } + @{ + Id = 'H40' + Rule = 'resolution: the sequence is the previous plus one over image and batch (Q17 B).' + Test = 'admission::tests::a_subject_with_a_resolution_in_force_refuses_another' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if resolution.sequence != expected {' + New = ' if false {' + } + @{ + Id = 'H41' + Rule = 'In force is recursive: a withdrawn resolution no longer closes its subject.' + Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + && !own(resolution) + && self.in_force(PipelineKind::Resolution, key) +'@ + New = @' + && !own(resolution) + && { let _ = key; true } +'@ + } + @{ + Id = 'H42' + Rule = 'stewardship: the sequence is the previous plus one, as a resolution''s is (Q18 A, Q20 A).' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if stewardship.sequence != expected {' + New = ' if false {' + } + @{ + Id = 'H43' + Rule = 'stewardship: at most one assignment of a repo is in force on a mind.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) { + return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() }); + } +'@ + New = '' + } + @{ + Id = 'H44' + Rule = 'Q19 A: a withdrawal cannot be withdrawn; the chain stops at depth two.' + Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + if subject_kind == K::Resolution + && let Some(D::Resolution(subject)) = docs.find(K::Resolution, subject_id) + && subject.subject.kind == K::Resolution + { + return Err(incompatible()); + } +'@ + New = '' + } + @{ + Id = 'H45' + Rule = 'derive: a derived resolution takes the subject''s next sequence, not always the first.' + Test = 'admission::tests::a_withdrawn_resolution_reopens_its_subject_and_stays_readable' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + sequence: docs.latest_resolution(&subject, None) + 1, + subject, + outcome: ResolutionOutcome::Answered +'@ + New = @' + sequence: 1, + subject, + outcome: ResolutionOutcome::Answered +'@ + } + @{ + Id = 'H46' + Rule = 'derive: H45''s other half, a derived stewardship takes the repo''s next sequence on this mind.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1,' + New = ' sequence: 1,' + } + @{ + Id = 'H47' + Rule = 'stewardship_of: only an in-force assignment counts, so a handed-away repo is not stewarded.' + Test = 'admission::tests::a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer' + File = 'crates/huginn-mind/src/admission.rs' + Old = @' + .filter(|(key, stewardship)| { + stewardship.instance == *mind + && stewardship.repo == *repo + && self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { + matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) + }) +'@ + New = @' + .filter(|(_key, stewardship)| { + let _ = hand_off_key; + stewardship.instance == *mind && stewardship.repo == *repo + }) +'@ + } + @{ + Id = 'H48' + Rule = 'A7: a document two batch documents cite is pinned once, not cancelled by the second citation.' + Test = 'admission::tests::every_cited_image_document_is_a_strong_read' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' strong.insert((reference.kind.type_id().to_string(), reference.id));' + New = @' + let pin = (reference.kind.type_id().to_string(), reference.id); + if !strong.insert(pin.clone()) { + strong.remove(&pin); + } +'@ + } + @{ + Id = 'H49' + Rule = 'finding: the in-force target is looked for in image and batch, not the image alone.' + Test = 'admission::tests::a_finding_names_evidence_locations_and_known_invariants' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' let target = docs.of_kind(K::Target).find_map(|(target_key, document)| match document {' + New = ' let target = docs.image.iter().map(|held| (held.key.as_str(), &held.document)).find_map(|(target_key, document)| match document {' + } + @{ + Id = 'H50' + Rule = 'resolution: the Answered coherence check reads the ruling wherever it is, batch or image.' + Test = 'admission::tests::a_ruling_answering_a_question_derives_the_answered_resolution_atomically' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' let Some(D::Ruling(ruling)) = docs.find(K::Ruling, &by.id.0) else { return Ok(()) };' + New = ' let Some(D::Ruling(ruling)) = docs.in_batch(K::Ruling, &by.id.0) else { return Ok(()) };' + } + @{ + Id = 'H51' + Rule = 'cut_report: the branch is compared as written; git''s refs are case-sensitive.' + Test = 'admission::tests::a_cut_report_cites_an_in_force_spec_and_agrees_with_it' + File = 'crates/huginn-mind/src/admission.rs' + Old = ' if report.branch != spec.branch {' + New = ' if !report.branch.0.eq_ignore_ascii_case(&spec.branch.0) {' + } ) }