Skip to main content

binoc_sdk/
correspondence.rs

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