Skip to main content

binoc_sdk/
correspondence.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    ArtifactFormat, BinocResult, DataAccess, Diagnostic, ExtractResult, GlobalClaim,
8    IdentityExtractor, IdentityFailurePolicy, IdentityToken, ItemRef, Segment, Summary,
9};
10
11/// Which side tree a node belongs to in the correspondence-first IR.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[serde(rename_all = "snake_case")]
15pub enum TreeSide {
16    Left,
17    Right,
18}
19
20impl TreeSide {
21    pub fn label(self) -> &'static str {
22        match self {
23            TreeSide::Left => "left",
24            TreeSide::Right => "right",
25        }
26    }
27}
28
29/// Stable identity of one side-tree node.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32pub struct NodeId {
33    pub side: TreeSide,
34    pub index: u32,
35}
36
37/// Product-facing projection metadata supplied by rules, not inferred by core.
38#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40pub struct ProjectionHint {
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub action: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub item_type: Option<String>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub tags: Vec<String>,
47    /// Tags this hint *removes* from the accumulated projection. Tag overlay is
48    /// union-only, so an annotator that supersedes an earlier framing (e.g. a
49    /// CFM-71 container reshape replacing a pair-time `binoc.move`) needs a way to
50    /// drop the now-stale tag — otherwise the IR carries contradictory tags
51    /// (inert in rendering, but incoherent in JSON). A retraction is honored
52    /// whenever tags are merged: the named tags are removed from the result and
53    /// can never be re-introduced by the *same* hint.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub retract_tags: Vec<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub summary: Option<Summary>,
58}
59
60pub fn projection_hint_is_default(hint: &ProjectionHint) -> bool {
61    hint == &ProjectionHint::default()
62}
63
64impl ProjectionHint {
65    pub fn action(mut self, action: impl Into<String>) -> Self {
66        self.action = Some(action.into());
67        self
68    }
69
70    pub fn item_type(mut self, item_type: impl Into<String>) -> Self {
71        self.item_type = Some(item_type.into());
72        self
73    }
74
75    pub fn tag(mut self, tag: impl Into<String>) -> Self {
76        self.tags.push(tag.into());
77        self
78    }
79
80    /// Declare that this hint retracts `tag` from the accumulated projection —
81    /// used to drop a superseded framing (e.g. a reshape annotator dropping the
82    /// pair-time `binoc.move`). See [`ProjectionHint::retract_tags`].
83    pub fn retract_tag(mut self, tag: impl Into<String>) -> Self {
84        self.retract_tags.push(tag.into());
85        self
86    }
87
88    pub fn summary(mut self, summary: impl Into<Summary>) -> Self {
89        self.summary = Some(summary.into());
90        self
91    }
92
93    pub fn merge_from(&mut self, other: &ProjectionHint) {
94        if self.action.is_none() {
95            self.action = other.action.clone();
96        }
97        if self.item_type.is_none() {
98            self.item_type = other.item_type.clone();
99        }
100        if self.summary.is_none() {
101            self.summary = other.summary.clone();
102        }
103        self.merge_tags(other);
104    }
105
106    /// Union `other`'s tags and retractions into `self`, then honor the combined
107    /// retraction set so the result never carries a retracted tag. Shared by
108    /// `merge_from` and `overlay_from` — the single point where tag sets combine.
109    fn merge_tags(&mut self, other: &ProjectionHint) {
110        self.tags.extend(other.tags.iter().cloned());
111        self.tags.sort();
112        self.tags.dedup();
113        self.retract_tags.extend(other.retract_tags.iter().cloned());
114        self.retract_tags.sort();
115        self.retract_tags.dedup();
116        if !self.retract_tags.is_empty() {
117            self.tags.retain(|tag| !self.retract_tags.contains(tag));
118        }
119    }
120
121    /// Overlay `other` onto `self`: every field `other` sets wins (unlike
122    /// [`merge_from`](Self::merge_from), which only fills gaps). Tags union.
123    pub fn overlay_from(&mut self, other: &ProjectionHint) {
124        if other.action.is_some() {
125            self.action = other.action.clone();
126        }
127        if other.item_type.is_some() {
128            self.item_type = other.item_type.clone();
129        }
130        if other.summary.is_some() {
131            self.summary = other.summary.clone();
132        }
133        self.merge_tags(other);
134    }
135}
136
137pub struct ProjectionAnnotationContext<'a> {
138    pub action: &'a str,
139    pub item_type: &'a str,
140    pub path: &'a str,
141    pub source_path: Option<&'a str>,
142    /// `item_type` of the *source* (left/from) endpoint of a link, when this line
143    /// is a reconciled correspondence. Lets an annotator notice that a container's
144    /// representation changed (e.g. "directory" -> "SQLite database") and render a
145    /// reshape instead of a bare move. `None` for unlinked add/remove lines and
146    /// when the source carried no explicit item_type. Core supplies the raw
147    /// strings; it never interprets them — the annotator owns the wording.
148    pub source_item_type: Option<&'a str>,
149    pub evidence: Option<&'a str>,
150    pub edits: &'a [Edit],
151    pub container: bool,
152    pub unlinked_side: Option<TreeSide>,
153}
154
155pub trait ProjectionAnnotator: Send + Sync {
156    fn name(&self) -> &str;
157    fn annotate(&self, ctx: &ProjectionAnnotationContext<'_>) -> ProjectionHint;
158}
159
160/// One rule registered with the correspondence-first saturation engine.
161#[derive(Clone)]
162pub enum CoreRule {
163    Expand(Arc<dyn ExpandRule>),
164    Parse(Arc<dyn ParseRule>),
165    Pair(Arc<dyn PairRule>),
166}
167
168impl CoreRule {
169    pub fn name(&self) -> String {
170        match self {
171            CoreRule::Expand(rule) => rule.descriptor().name,
172            CoreRule::Parse(rule) => rule.descriptor().name,
173            CoreRule::Pair(rule) => rule.descriptor().name,
174        }
175    }
176}
177
178/// In-process registration surface for correspondence rule packs.
179///
180/// The engine that consumes this type lives in `binoc-core`, but the type stays
181/// in the SDK so stdlib and third-party packs can be configured without
182/// depending on host internals.
183#[derive(Default, Clone)]
184pub struct CorrespondenceEngineConfig {
185    pub rules: Vec<CoreRule>,
186    pub writers: Vec<Arc<dyn EditListWriter>>,
187    pub compaction: Vec<Arc<dyn CompactionRule>>,
188    pub annotators: Vec<Arc<dyn ProjectionAnnotator>>,
189    /// Partition-identity extractors, keyed by artifact format (CFM-72). The
190    /// engine dispatches these JIT over the *unmatched* residue when a
191    /// partition-capable pair rule asks for a node's identity tokens; they are
192    /// never stored in the IR or gold. A format with no extractor here is simply
193    /// not partition-capable.
194    pub identity_extractors: Vec<Arc<dyn IdentityExtractor>>,
195    pub row_keys: BTreeMap<String, Vec<String>>,
196    pub row_identity_policies: BTreeMap<String, RowIdentityPolicies>,
197    pub root_projection: ProjectionHint,
198    pub dataset_configurator: Option<Arc<dyn CorrespondenceDatasetConfigurator>>,
199}
200
201pub trait CorrespondenceDatasetConfigurator: Send + Sync {
202    fn configure(
203        &self,
204        config: &mut CorrespondenceEngineConfig,
205        dataset: &serde_json::Value,
206        left_root: &ItemRef,
207        right_root: &ItemRef,
208        data: &dyn DataAccess,
209    ) -> BinocResult<Vec<Diagnostic>>;
210}
211
212/// Metadata-only declarative filter over an [`ItemRef`].
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct NodeMatch {
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub is_dir: Option<bool>,
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub extensions: Vec<String>,
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub media_types: Vec<String>,
222}
223
224impl NodeMatch {
225    pub fn matches(&self, item: &ItemRef) -> bool {
226        if let Some(expected) = self.is_dir {
227            if item.is_dir != expected {
228                return false;
229            }
230        }
231        if !self.extensions.is_empty() {
232            let ext = item.extension();
233            if !ext
234                .as_ref()
235                .is_some_and(|ext| self.extensions.iter().any(|candidate| candidate == ext))
236            {
237                return false;
238            }
239        }
240        if !self.media_types.is_empty() {
241            let media_type = item.media_type.as_deref().unwrap_or("");
242            if !self
243                .media_types
244                .iter()
245                .any(|candidate| candidate == media_type)
246            {
247                return false;
248            }
249        }
250        true
251    }
252}
253
254/// Shape filter for edit-list writers.
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257#[serde(rename_all = "snake_case")]
258pub enum ShapeFilter {
259    #[default]
260    Any,
261    Container,
262    Leaf,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267pub struct ExpandDescriptor {
268    pub name: String,
269    pub input: NodeMatch,
270    #[serde(default)]
271    pub fires_beneath_settled: bool,
272}
273
274pub trait ExpandRule: Send + Sync {
275    fn descriptor(&self) -> ExpandDescriptor;
276    fn expand(&self, item: &ItemRef, data: &dyn DataAccess) -> BinocResult<ExpandOutput>;
277}
278
279#[derive(Debug, Clone, Default, Serialize, Deserialize)]
280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
281pub struct ExpandOutput {
282    pub children: Vec<ItemRef>,
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub diagnostics: Vec<Diagnostic>,
285}
286
287impl From<Vec<ItemRef>> for ExpandOutput {
288    fn from(children: Vec<ItemRef>) -> Self {
289        Self {
290            children,
291            diagnostics: Vec::new(),
292        }
293    }
294}
295
296/// One slot in a parse rule's correlated input member-set (CFM-83).
297///
298/// A member-match is a [`NodeMatch`] plus whether the slot must be filled for a
299/// group to form. The ordered member list of a [`ParseDescriptor`] is its
300/// `input` anchor (always a required size-1 member) followed by any
301/// `extra_members`. A single-input parser declares no extra members, so its
302/// member-set is exactly `[{ input, required: true }]` — the size-1 degenerate
303/// case the engine still drives through the same enumeration path.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
306pub struct MemberMatch {
307    #[serde(rename = "match")]
308    pub matcher: NodeMatch,
309    #[serde(default)]
310    pub required: bool,
311}
312
313impl MemberMatch {
314    /// A required member slot.
315    pub fn required(matcher: NodeMatch) -> Self {
316        Self {
317            matcher,
318            required: true,
319        }
320    }
321
322    /// An optional member slot — a group may form without it.
323    pub fn optional(matcher: NodeMatch) -> Self {
324        Self {
325            matcher,
326            required: false,
327        }
328    }
329}
330
331/// A plain `NodeMatch` is the size-1 required member: the ergonomic single-input
332/// case promised by CFM-83's ADR.
333impl From<NodeMatch> for MemberMatch {
334    fn from(matcher: NodeMatch) -> Self {
335        MemberMatch::required(matcher)
336    }
337}
338
339/// How the engine groups candidate sibling nodes into one parse-claim input.
340///
341/// `SharedStem` (the default) groups a container's children by *shared basename
342/// under the same parent*, where the basename is the file name with only its
343/// final extension removed (`roads.v2.shp` and `roads.v2.dbf` share `roads.v2`;
344/// `roads.shp` stays `roads`). This is the only generic, format-agnostic grouping
345/// knowledge core needs, and it keeps versioned sibling sets distinct. The
346/// capture/template generalization (for suffix sidecars named *off* an anchor
347/// stem rather than sharing it, e.g. `data.tif` + `data.tif.aux.xml`) is a
348/// deferred seam; it reuses `DeclaredPair`'s `selector_captures`/`expand_template`
349/// vocabulary. Until a real format needs it, only `SharedStem` is implemented.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
351#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
352#[serde(rename_all = "snake_case")]
353pub enum Correlation {
354    /// Same parent container + shared basename stem.
355    #[default]
356    SharedStem,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
361pub struct ParseDescriptor {
362    pub name: String,
363    /// The anchor member: the required, defining node of the claim (e.g. the
364    /// `.shp`). This stays a plain [`NodeMatch`] so the 1-input case — the
365    /// overwhelming majority of parse rules — is unchanged. Additional members
366    /// and the correlation key for a fusing (multi-input) claim are declared on
367    /// the [`ParseRule`] trait ([`ParseRule::extra_members`] /
368    /// [`ParseRule::correlation`]), keeping the blast radius of CFM-83 off every
369    /// single-input descriptor literal.
370    pub input: NodeMatch,
371    pub output: ArtifactFormat,
372    #[serde(default)]
373    pub fires_beneath_settled: bool,
374}
375
376/// A resolved group of member nodes handed to a multi-input [`ParseRule`].
377///
378/// The `anchor` is the required defining node (always present). `members` holds
379/// the resolved [`ItemRef`] for every slot in descriptor order, `None` for an
380/// unfilled optional slot. Index 0 is always the anchor (`Some`). A single-input
381/// parse sees a group with just the anchor.
382#[derive(Debug, Clone)]
383pub struct ParseGroup {
384    pub anchor: ItemRef,
385    pub members: Vec<Option<ItemRef>>,
386}
387
388impl ParseGroup {
389    /// A trivial size-1 group wrapping a single anchor node.
390    pub fn single(anchor: ItemRef) -> Self {
391        Self {
392            members: vec![Some(anchor.clone())],
393            anchor,
394        }
395    }
396
397    /// The resolved member at slot `index` (descriptor order), if filled.
398    pub fn member(&self, index: usize) -> Option<&ItemRef> {
399        self.members.get(index).and_then(Option::as_ref)
400    }
401
402    /// All filled members (anchor + present optionals), in slot order.
403    pub fn present(&self) -> impl Iterator<Item = &ItemRef> {
404        self.members.iter().filter_map(Option::as_ref)
405    }
406}
407
408pub trait ParseRule: Send + Sync {
409    fn descriptor(&self) -> ParseDescriptor;
410
411    /// Parse a single anchor node. This is the single-input entry point every
412    /// ordinary parser implements; the member-set generalization (CFM-83) does
413    /// not touch it.
414    fn parse(&self, item: &ItemRef, data: &dyn DataAccess) -> BinocResult<ParseOutput>;
415
416    /// Additional member slots beyond the anchor (`descriptor().input`), in
417    /// order — e.g. `.shx`, `.dbf`, `.prj`, `.cpg` for a fusing shapefile claim.
418    /// The default is empty: a single-input claim. The full ordered member-set
419    /// is the anchor (always a required size-1 member) followed by these; see
420    /// [`member_set`].
421    fn extra_members(&self) -> Vec<MemberMatch> {
422        Vec::new()
423    }
424
425    /// How candidate sibling groups are enumerated for a multi-input claim.
426    /// Ignored when [`extra_members`](Self::extra_members) is empty.
427    fn correlation(&self) -> Correlation {
428        Correlation::SharedStem
429    }
430
431    /// Parse a resolved correlated member group. The default delegates to
432    /// [`parse`](Self::parse) on the anchor, so single-input rules need not
433    /// implement it. A fusing rule (e.g. the shapefile layer) overrides this to
434    /// read its `.shp`/`.dbf`/`.prj` members together and emit one fused node;
435    /// it returns an empty [`ParseOutput`] to **decline** when the group is not a
436    /// real instance of its format, releasing the members to smaller claims.
437    fn parse_group(&self, group: &ParseGroup, data: &dyn DataAccess) -> BinocResult<ParseOutput> {
438        self.parse(&group.anchor, data)
439    }
440}
441
442/// The full ordered member-set of a parse rule: the anchor (always a required
443/// size-1 member) followed by the rule's [`extra_members`](ParseRule::extra_members).
444/// This is the list the engine fills by [`NodeMatch`] when enumerating candidate
445/// sibling groups; index 0 is always the required anchor.
446pub fn member_set(rule: &dyn ParseRule) -> Vec<MemberMatch> {
447    let mut members = vec![MemberMatch::required(rule.descriptor().input)];
448    members.extend(rule.extra_members());
449    members
450}
451
452/// A parse claim's arity: the number of declared member slots (anchor + extras).
453/// Drives arity-descending precedence — larger claims are attempted first.
454pub fn parse_arity(rule: &dyn ParseRule) -> usize {
455    1 + rule.extra_members().len()
456}
457
458#[derive(Debug, Clone, Default, Serialize, Deserialize)]
459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
460pub struct ParseOutput {
461    pub bytes: Vec<u8>,
462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
463    pub diagnostics: Vec<Diagnostic>,
464    #[serde(default, skip_serializing_if = "Vec::is_empty")]
465    pub children: Vec<ParsedChild>,
466    /// Additional artifacts to publish on the parsed node itself, beyond the
467    /// primary `bytes` artifact (whose format is the descriptor's `output`).
468    /// This is the channel for a second artifact on a node — e.g. a
469    /// `parser_metadata_v1` bag riding alongside a `tabular_v1` leaf, or on a
470    /// container that publishes no primary `bytes`. Each rides as its own
471    /// format, diffed independently by a format-matched writer.
472    #[serde(default, skip_serializing_if = "Vec::is_empty")]
473    pub artifacts: Vec<ParsedArtifact>,
474    /// Projection overlay for the node being parsed. A container parse (one that
475    /// emits children and no parent artifact) uses this to name what kind of
476    /// container the node is — e.g. `item_type("SQLite database")` — since the
477    /// node would otherwise inherit only an extension-based guess. Fields set
478    /// here win over the node's existing projection (see
479    /// [`ProjectionHint::overlay_from`]).
480    #[serde(default, skip_serializing_if = "projection_hint_is_default")]
481    pub projection: ProjectionHint,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
486pub struct ParsedChild {
487    pub item: ItemRef,
488    #[serde(default, skip_serializing_if = "Vec::is_empty")]
489    pub artifacts: Vec<ParsedArtifact>,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
494pub struct ParsedArtifact {
495    pub format: ArtifactFormat,
496    pub bytes: Vec<u8>,
497}
498
499impl From<Vec<u8>> for ParseOutput {
500    fn from(bytes: Vec<u8>) -> Self {
501        Self {
502            bytes,
503            diagnostics: Vec::new(),
504            children: Vec::new(),
505            artifacts: Vec::new(),
506            projection: ProjectionHint::default(),
507        }
508    }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
513pub struct PairDescriptor {
514    pub name: String,
515    #[serde(default)]
516    pub emits: Vec<String>,
517    /// Artifact formats this rule consumes pre-link to decide pairings.
518    ///
519    /// This is a declared read-set, the pairing-side analogue of a parse
520    /// rule's `output`. A rule that pairs nodes by their parsed content (rather
521    /// than by raw bytes, hashes, or paths) lists those formats here so the
522    /// engine knows the artifacts must be materialized on unlinked nodes before
523    /// the rule runs. Rules that read no artifacts leave this empty.
524    #[serde(default)]
525    pub reads: Vec<ArtifactFormat>,
526    #[serde(default)]
527    pub sees_beneath_settled: bool,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
532pub struct LinkProposal {
533    pub left: u32,
534    pub right: u32,
535    pub evidence: String,
536    #[serde(default)]
537    pub settled: bool,
538    #[serde(default)]
539    pub projection: ProjectionHint,
540}
541
542pub trait PairRule: Send + Sync {
543    fn descriptor(&self) -> PairDescriptor;
544    fn propose(&self, view: &dyn EngineView, data: &dyn DataAccess) -> BinocResult<PairOutput>;
545    fn final_diagnostics(
546        &self,
547        _view: &dyn EngineView,
548        _data: &dyn DataAccess,
549    ) -> BinocResult<Vec<Diagnostic>> {
550        Ok(Vec::new())
551    }
552
553    /// Global, non-tree claims this rule asserts about the *final* settled link
554    /// graph (CFM-72). Called once after saturation, like
555    /// [`final_diagnostics`](Self::final_diagnostics); the engine collects the
556    /// result into `Changeset.claims`. A rule that reshapes the link set into a
557    /// split/merge fan-out reports the claim here so the assertion is produced
558    /// once, from the converged state, rather than re-emitted every round.
559    fn final_claims(
560        &self,
561        _view: &dyn EngineView,
562        _data: &dyn DataAccess,
563    ) -> BinocResult<Vec<GlobalClaim>> {
564        Ok(Vec::new())
565    }
566}
567
568#[derive(Debug, Clone, Default, Serialize, Deserialize)]
569#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
570pub struct PairOutput {
571    pub proposals: Vec<LinkProposal>,
572    #[serde(default, skip_serializing_if = "Vec::is_empty")]
573    pub diagnostics: Vec<Diagnostic>,
574}
575
576impl From<Vec<LinkProposal>> for PairOutput {
577    fn from(proposals: Vec<LinkProposal>) -> Self {
578        Self {
579            proposals,
580            diagnostics: Vec::new(),
581        }
582    }
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
587pub struct LinkRef {
588    pub index: usize,
589    pub left: NodeId,
590    pub right: NodeId,
591    pub evidence: String,
592    pub proposer: String,
593    pub priority: u32,
594    pub settled: bool,
595    #[serde(default)]
596    pub projection: ProjectionHint,
597}
598
599pub trait EngineView {
600    fn root(&self, side: TreeSide) -> NodeId;
601    fn visible(&self, id: NodeId) -> bool;
602    fn nodes(&self, side: TreeSide) -> Vec<NodeId>;
603    fn item(&self, id: NodeId) -> &ItemRef;
604    fn parent(&self, id: NodeId) -> Option<NodeId>;
605    fn children(&self, id: NodeId) -> Vec<NodeId>;
606    fn has_children(&self, id: NodeId) -> bool;
607    fn is_linked(&self, id: NodeId) -> bool;
608    fn links(&self) -> Vec<LinkRef>;
609    fn links_of(&self, id: NodeId) -> Vec<LinkRef>;
610    fn artifact_bytes(
611        &self,
612        id: NodeId,
613        format: &ArtifactFormat,
614        data: &dyn DataAccess,
615    ) -> BinocResult<Option<Vec<u8>>>;
616
617    /// Partition-identity tokens for a node (CFM-72), or `None` when no
618    /// registered [`IdentityExtractor`] matches an artifact the node carries.
619    ///
620    /// The engine owns the dispatch: it tries each registered extractor's format
621    /// against the node's artifacts and runs the first match. The rule stays
622    /// format-ignorant — it sees only opaque, globally-comparable tokens — so the
623    /// same partition rule serves every partition-capable format. Computed JIT
624    /// over whatever node the caller asks about (intended: the unmatched
625    /// residue); never stored.
626    fn identity_tokens(
627        &self,
628        _id: NodeId,
629        _data: &dyn DataAccess,
630    ) -> BinocResult<Option<Vec<IdentityToken>>> {
631        Ok(None)
632    }
633}
634
635/// One open-vocabulary edit in a link's edit list.
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638pub struct Edit {
639    pub verb: String,
640    pub params: serde_json::Value,
641    #[serde(default)]
642    pub projection: EditProjection,
643    /// Provenance tag: which content type produced this edit. For an artifact
644    /// writer it is the artifact format's display string (e.g.
645    /// `binoc.tabular.v1`); for a structural writer (container/text/fallback) it
646    /// is the writer's name. Set by the dispatcher after a writer runs — writers
647    /// do not populate it themselves — so the merged per-link edit list can be
648    /// sliced back into per-content-type segments for format-scoped compaction,
649    /// extract routing, and grouped summary/projection. `None` only for
650    /// hand-built edits in tests that never pass through dispatch.
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub provenance: Option<String>,
653}
654
655impl Edit {
656    pub fn new(verb: impl Into<String>, params: serde_json::Value) -> Self {
657        Self {
658            verb: verb.into(),
659            params,
660            projection: EditProjection::default(),
661            provenance: None,
662        }
663    }
664
665    /// Stamp this edit's provenance (the producing format/writer). Used by the
666    /// dispatcher; idempotent and chainable.
667    pub fn with_provenance(mut self, provenance: impl Into<String>) -> Self {
668        self.provenance = Some(provenance.into());
669        self
670    }
671
672    pub fn hidden(mut self) -> Self {
673        self.projection.visible = false;
674        self
675    }
676
677    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
678        self.projection.hint.tags.push(tag.into());
679        self
680    }
681
682    pub fn with_item_type(mut self, item_type: impl Into<String>) -> Self {
683        self.projection.hint.item_type = Some(item_type.into());
684        self
685    }
686
687    pub fn with_summary(mut self, summary: impl Into<Summary>) -> Self {
688        self.projection.hint.summary = Some(summary.into());
689        self
690    }
691}
692
693#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
695pub struct EditProjection {
696    #[serde(default = "default_visible")]
697    pub visible: bool,
698    #[serde(default)]
699    pub hint: ProjectionHint,
700}
701
702impl Default for EditProjection {
703    fn default() -> Self {
704        Self {
705            visible: true,
706            hint: ProjectionHint::default(),
707        }
708    }
709}
710
711fn default_visible() -> bool {
712    true
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize)]
716#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
717pub struct WriterDescriptor {
718    pub name: String,
719    #[serde(default)]
720    pub formats: Vec<ArtifactFormat>,
721    pub input: NodeMatch,
722    #[serde(default)]
723    pub shape: ShapeFilter,
724    /// Marks the last-resort structural writer (the byte/hash fallback). Under
725    /// composing dispatch (CFM-81) the fallback runs only when no other writer
726    /// claimed the link; a fallback writer always declares empty `formats`.
727    #[serde(default)]
728    pub fallback: bool,
729}
730
731pub struct LinkCtx<'a> {
732    pub view: &'a dyn EngineView,
733    pub link: LinkRef,
734    pub row_keys: &'a [String],
735    pub row_identity_policies: RowIdentityPolicies,
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub struct RowIdentityPolicies {
740    pub on_null_key: IdentityFailurePolicy,
741    pub on_duplicate_key: IdentityFailurePolicy,
742}
743
744impl Default for RowIdentityPolicies {
745    fn default() -> Self {
746        Self {
747            on_null_key: IdentityFailurePolicy::Diagnostic,
748            on_duplicate_key: IdentityFailurePolicy::Diagnostic,
749        }
750    }
751}
752
753pub trait EditListWriter: Send + Sync {
754    fn descriptor(&self) -> WriterDescriptor;
755    fn write(&self, ctx: &LinkCtx<'_>, data: &dyn DataAccess) -> BinocResult<Option<WriteOutput>>;
756    fn extract(
757        &self,
758        _ctx: &LinkCtx<'_>,
759        _edits: &[Edit],
760        _aspect: &str,
761        _data: &dyn DataAccess,
762    ) -> BinocResult<Option<ExtractResult>> {
763        Ok(None)
764    }
765}
766
767#[derive(Debug, Clone, Default, Serialize, Deserialize)]
768#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
769pub struct WriteOutput {
770    pub edits: Vec<Edit>,
771    #[serde(default, skip_serializing_if = "Vec::is_empty")]
772    pub diagnostics: Vec<Diagnostic>,
773}
774
775impl From<Vec<Edit>> for WriteOutput {
776    fn from(edits: Vec<Edit>) -> Self {
777        Self {
778            edits,
779            diagnostics: Vec::new(),
780        }
781    }
782}
783
784pub trait CompactionRule: Send + Sync {
785    fn name(&self) -> &str;
786
787    /// The artifact format whose provenance-scoped segment this rule rewrites.
788    /// The dispatcher slices a link's merged edit list down to the edits tagged
789    /// with this format before calling [`rewrite`](Self::rewrite), so a rule
790    /// never sees or rewrites another content type's edits. `None` means the
791    /// rule operates on the whole (unsegmented) edit list — reserved for
792    /// cross-content-type or structural compaction; format-specific rules must
793    /// declare their format.
794    fn format(&self) -> Option<ArtifactFormat> {
795        None
796    }
797
798    fn rewrite(
799        &self,
800        ctx: &LinkCtx<'_>,
801        edits: &[Edit],
802        data: &dyn DataAccess,
803    ) -> BinocResult<Option<Vec<Edit>>>;
804}
805
806/// Generic summary for edit-count fallback projection.
807pub fn edit_count_summary(edit_count: usize) -> Summary {
808    Summary(vec![
809        Segment::Uint(edit_count as u64),
810        Segment::Text(format!(" edit{}", if edit_count == 1 { "" } else { "s" })),
811    ])
812}
813
814#[cfg(test)]
815mod projection_hint_tests {
816    use super::*;
817
818    #[test]
819    fn overlay_retracts_a_superseded_tag() {
820        // A reshape framing supersedes a pair-time move: the move tag must not
821        // survive into the accumulated projection, even though overlay is
822        // otherwise union-only.
823        let mut acc = ProjectionHint::default()
824            .tag("binoc.move")
825            .tag("binoc.keep");
826        let reshape = ProjectionHint::default()
827            .tag("binoc.container-reshape")
828            .retract_tag("binoc.move");
829        acc.overlay_from(&reshape);
830        assert!(acc.tags.contains(&"binoc.container-reshape".to_string()));
831        assert!(acc.tags.contains(&"binoc.keep".to_string()));
832        assert!(!acc.tags.contains(&"binoc.move".to_string()));
833    }
834
835    #[test]
836    fn retraction_holds_regardless_of_union_order() {
837        // Retracting and adding the same tag in one hint: the retraction wins, so
838        // a hint can never both assert and drop a tag.
839        let mut acc = ProjectionHint::default();
840        let hint = ProjectionHint::default()
841            .tag("binoc.move")
842            .retract_tag("binoc.move");
843        acc.merge_from(&hint);
844        assert!(!acc.tags.contains(&"binoc.move".to_string()));
845    }
846}