From 511afa2f440924c83d54022f7f70a10c1092b426 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 05:16:05 +0200 Subject: [PATCH] RS-3 1: the edge list moves to its owner (docs.rs citations) docs.rs::citations and CitationRole are the one edge list: the fifteen roles, one per referring leaf field. Admission's A7 references() is a map over them, carrying the Missing classification by role and the hand-off source-side filter, with the same refusals in the same order. kind_of_id and outcome_references leave admission; the CitesResolvedDocument check reads outcome_citations. Co-Authored-By: Claude Sonnet 5 --- crates/huginn-mind/src/admission.rs | 102 +++++----------- crates/huginn-mind/src/docs.rs | 181 +++++++++++++++++++++++++++- crates/huginn-mind/src/lib.rs | 1 + 3 files changed, 209 insertions(+), 75 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 4734da2..8ea8f8a 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -37,7 +37,7 @@ use epiphany_pipeline::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::docs::{Docs, Staged}; +use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations}; use crate::mind::{HuginnMindEpoch, Mind, unavailable}; use crate::receipt::{self, CommitOutcome, PipelineProvenance}; use crate::refusal::MindRefusal; @@ -170,13 +170,6 @@ impl Mind { } } -/// The kind segment of a full id, for the one field typed `Short` whose -/// referent may be of any kind. -fn kind_of_id(id: &str) -> Option { - let name = id.split(':').nth(1)?; - PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) -} - /// A3 through the leaf, then A5. fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { validate_pipeline_write_envelope(&envelope)?; @@ -212,74 +205,35 @@ struct Reference { missing: Missing, } -fn reference(kind: PipelineKind, id: &str) -> Reference { - Reference { kind, id: id.to_string(), missing: Missing::Reference } -} - -fn referenced(pipeline_ref: &PipelineRef) -> Reference { - reference(pipeline_ref.kind, &pipeline_ref.id.0) -} - -fn outcome_references(outcome: &ResolutionOutcome) -> Vec { - match outcome { - ResolutionOutcome::Superseded { by } => by - .iter() - .map(|supersessor| Reference { kind: supersessor.kind, id: supersessor.id.0.clone(), missing: Missing::Supersessor }) - .collect(), - ResolutionOutcome::Answered { by } => vec![referenced(by)], - ResolutionOutcome::Fixed { by: Some(by), .. } => vec![referenced(by)], - ResolutionOutcome::Deferred { to } => vec![referenced(to)], - ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => { - vec![] - } - } -} - -/// A7's field list: every `PipelineRef` and every full-id `Short` field. -/// `Fixed.commit` and `ForeignRef` are syntax only. A hand-off's documents -/// are cited on the source side only; the receiving side imports them -/// afterwards (Cut 12). +/// A7's field list is `docs::citations`: every `PipelineRef` and every +/// full-id `Short` field, each carrying the refusal its absence earns. A +/// hand-off's documents are cited on the source side only, and each must read +/// as an id of a kind; the receiving side imports them afterwards (Cut 12). fn references(staged: &Staged, mind: &Slug) -> Result, MindRefusal> { - use PipelineDocument as D; - use PipelineKind as K; - let mut refs = Vec::new(); - match &staged.document { - D::Question(question) => refs.extend(question.raised_in.as_ref().map(referenced)), - D::Ruling(ruling) => refs.extend(ruling.answers.as_ref().map(|question| reference(K::Question, &question.0))), - D::CutSpec(spec) => { - refs.extend(spec.rulings.iter().map(|ruling| reference(K::Ruling, &ruling.0))); - refs.extend(spec.questions.iter().map(|question| reference(K::Question, &question.0))); + if let PipelineDocument::HandOff(hand_off) = &staged.document { + if hand_off.from_instance != *mind { + return Ok(Vec::new()); } - D::CutReport(report) => { - refs.push(Reference { - kind: K::CutSpec, - id: report.cut_spec.0.clone(), - missing: Missing::SpecOfReport(staged.key.clone()), - }); - refs.extend(report.forks.iter().map(|question| reference(K::Question, &question.0))); - } - D::Verdict(verdict) => { - refs.push(reference(K::CutReport, &verdict.cut_report.0)); - refs.extend(verdict.claims.iter().flat_map(|claim| claim.findings.iter()).map(|finding| reference(K::Finding, &finding.0))); - } - D::Finding(finding) => refs.push(reference(K::Verdict, &finding.verdict.0)), - D::FollowUp(follow_up) => refs.push(referenced(&follow_up.source)), - D::Resolution(resolution) => { - refs.push(referenced(&resolution.subject)); - refs.extend(outcome_references(&resolution.outcome)); - } - D::HandOff(hand_off) if hand_off.from_instance == *mind => { - for document in &hand_off.documents { - let kind = kind_of_id(&document.0).ok_or_else(|| PipelineRefusal::InvalidFormat { - field: "hand_off.documents".into(), - value: document.0.clone(), - })?; - refs.push(reference(kind, &document.0)); + if let Some(document) = hand_off.documents.iter().find(|document| kind_of_id(&document.0).is_none()) { + return Err(PipelineRefusal::InvalidFormat { + field: "hand_off.documents".into(), + value: document.0.clone(), } + .into()); } - D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::HandOff(_) => {} } - Ok(refs) + Ok(citations(&staged.document) + .into_iter() + .map(|(role, target)| Reference { + kind: target.kind, + id: target.id.0, + missing: match role { + CitationRole::CutSpec => Missing::SpecOfReport(staged.key.clone()), + CitationRole::SupersededBy => Missing::Supersessor, + _ => Missing::Reference, + }, + }) + .collect()) } /// A7: every referent is in the batch or the image; the image ones are the @@ -652,9 +606,9 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M later: later.into(), }); } - for referent in outcome_references(&resolution.outcome) { - if !docs.in_force(referent.kind, &referent.id) { - return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id }); + for (_, referent) in outcome_citations(&resolution.outcome) { + if !docs.in_force(referent.kind, &referent.id.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id.0 }); } } match &resolution.outcome { diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index 8a21b2c..1f1b033 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -8,8 +8,10 @@ use cultcache_rs::CultCacheEnvelope; use epiphany_pipeline::{ OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineStewardship, ResolutionOutcome, - Slug, + Short, Slug, }; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use crate::refusal::MindRefusal; @@ -262,3 +264,180 @@ fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { _ => false, } } + +/// The referring field of a citation edge, one per leaf field that names +/// another document. The wire spelling is the field's own name, so a client +/// reads an edge's role as the field it came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CitationRole { + RaisedIn, + Answers, + Rulings, + Questions, + CutSpec, + Forks, + CutReport, + Findings, + Verdict, + Source, + Subject, + SupersededBy, + ResolvedBy, + DeferredTo, + Documents, +} + +impl CitationRole { + pub const ALL: [CitationRole; 15] = [ + Self::RaisedIn, + Self::Answers, + Self::Rulings, + Self::Questions, + Self::CutSpec, + Self::Forks, + Self::CutReport, + Self::Findings, + Self::Verdict, + Self::Source, + Self::Subject, + Self::SupersededBy, + Self::ResolvedBy, + Self::DeferredTo, + Self::Documents, + ]; + + /// The wire spelling, equal to the referring field's name. + pub fn name(self) -> &'static str { + match self { + Self::RaisedIn => "raised_in", + Self::Answers => "answers", + Self::Rulings => "rulings", + Self::Questions => "questions", + Self::CutSpec => "cut_spec", + Self::Forks => "forks", + Self::CutReport => "cut_report", + Self::Findings => "findings", + Self::Verdict => "verdict", + Self::Source => "source", + Self::Subject => "subject", + Self::SupersededBy => "superseded_by", + Self::ResolvedBy => "resolved_by", + Self::DeferredTo => "deferred_to", + Self::Documents => "documents", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|role| role.name() == name) + } + + /// The kind whose documents carry this field. + pub fn carrier(self) -> PipelineKind { + use PipelineKind as K; + match self { + Self::RaisedIn => K::Question, + Self::Answers => K::Ruling, + Self::Rulings | Self::Questions => K::CutSpec, + Self::CutSpec | Self::Forks => K::CutReport, + Self::CutReport | Self::Findings => K::Verdict, + Self::Verdict => K::Finding, + Self::Source => K::FollowUp, + Self::Subject | Self::SupersededBy | Self::ResolvedBy | Self::DeferredTo => K::Resolution, + Self::Documents => K::HandOff, + } + } + + /// The kinds a referent of this field may be: the one kind the field is + /// typed to, or every kind for the fields typed `PipelineRef` or a full + /// id of any kind. "Any" is spelled as all thirteen, never as none. + pub fn target_kinds(self) -> Vec { + use PipelineKind as K; + match self { + Self::Answers | Self::Questions | Self::Forks => vec![K::Question], + Self::Rulings => vec![K::Ruling], + Self::CutSpec => vec![K::CutSpec], + Self::CutReport => vec![K::CutReport], + Self::Findings => vec![K::Finding], + Self::Verdict => vec![K::Verdict], + Self::RaisedIn + | Self::Source + | Self::Subject + | Self::SupersededBy + | Self::ResolvedBy + | Self::DeferredTo + | Self::Documents => PipelineKind::ALL.to_vec(), + } + } +} + +/// The kind segment of a full id, for the fields typed `Short` whose referent +/// may be of any kind. +pub(crate) fn kind_of_id(id: &str) -> Option { + let name = id.split(':').nth(1)?; + PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) +} + +fn cited(kind: PipelineKind, id: &Short) -> PipelineRef { + PipelineRef { kind, id: id.clone() } +} + +/// What a resolution's outcome names, by the field that names it. +pub(crate) fn outcome_citations(outcome: &ResolutionOutcome) -> Vec<(CitationRole, PipelineRef)> { + match outcome { + ResolutionOutcome::Superseded { by } => by.iter().map(|by| (CitationRole::SupersededBy, by.clone())).collect(), + ResolutionOutcome::Answered { by } => vec![(CitationRole::ResolvedBy, by.clone())], + ResolutionOutcome::Fixed { by: Some(by), .. } => vec![(CitationRole::ResolvedBy, by.clone())], + ResolutionOutcome::Deferred { to } => vec![(CitationRole::DeferredTo, to.clone())], + ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => { + vec![] + } + } +} + +/// Every citation edge a document carries, derived from its leaf fields each +/// time it is asked and never stored: the one edge list. Admission's A7 reads +/// it to require the referents, and the read side's rows read it as their +/// references. `Fixed.commit`, `ForeignRef` precedents and the labels +/// (`depends_on`, `invariants`, `promise`) are not edges. A hand-off's +/// `documents` entry whose kind segment does not read cites nothing here; +/// admission refuses such an entry on the source side before it asks. +pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, PipelineRef)> { + use CitationRole as R; + use PipelineDocument as D; + use PipelineKind as K; + let mut edges = Vec::new(); + match document { + D::Question(question) => edges.extend(question.raised_in.iter().map(|to| (R::RaisedIn, to.clone()))), + D::Ruling(ruling) => edges.extend(ruling.answers.iter().map(|to| (R::Answers, cited(K::Question, to)))), + D::CutSpec(spec) => { + edges.extend(spec.rulings.iter().map(|to| (R::Rulings, cited(K::Ruling, to)))); + edges.extend(spec.questions.iter().map(|to| (R::Questions, cited(K::Question, to)))); + } + D::CutReport(report) => { + edges.push((R::CutSpec, cited(K::CutSpec, &report.cut_spec))); + edges.extend(report.forks.iter().map(|to| (R::Forks, cited(K::Question, to)))); + } + D::Verdict(verdict) => { + edges.push((R::CutReport, cited(K::CutReport, &verdict.cut_report))); + edges.extend( + verdict + .claims + .iter() + .flat_map(|claim| claim.findings.iter()) + .map(|to| (R::Findings, cited(K::Finding, to))), + ); + } + D::Finding(finding) => edges.push((R::Verdict, cited(K::Verdict, &finding.verdict))), + D::FollowUp(follow_up) => edges.push((R::Source, follow_up.source.clone())), + D::Resolution(resolution) => { + edges.push((R::Subject, resolution.subject.clone())); + edges.extend(outcome_citations(&resolution.outcome)); + } + D::HandOff(hand_off) => edges.extend( + hand_off.documents.iter().filter_map(|to| kind_of_id(&to.0).map(|kind| (R::Documents, cited(kind, to)))), + ), + D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) => {} + } + edges +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 6c73109..778d652 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -35,6 +35,7 @@ pub mod wire; #[cfg(test)] pub(crate) mod fixtures; +pub use docs::CitationRole; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; pub use mind::{HuginnMindEpoch, Mind}; pub use query::{