Skip to main content

lace_sans_io/logic/
facet_constraints.rs

1//! Canonical local facet composition, before the fixed two-operand exchange plan.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use super::exposure_view::ExposureView;
6use super::limits::RuleCollector;
7use super::profile::{
8    ADVERTISED_FIELD, EXPOSE_RECORD, FIELD, SELECT_ADVERTISED_CANDIDATE, SELECT_STORED_RECORD,
9};
10use super::{BodyAtom, DatalogLimits, InterlaceModule, PreparationError, Rule, Symbol, Term};
11
12/// Selection and exposure facets intersect across contributors; the
13/// directional vetoes union without wrappers.
14const INTERSECTED_FACETS: [&str; 3] = [
15    SELECT_STORED_RECORD.name,
16    SELECT_ADVERTISED_CANDIDATE.name,
17    EXPOSE_RECORD.name,
18];
19
20/// The guest view: the intersection of the authoring `ExposeRecord` outputs.
21/// Generated names follow `constraint_` directly, so renamed inputs, whose
22/// names follow `constraint{index}_`, cannot capture them.
23const GUEST_VIEW: &str = "constraint_authoring_exposerecord";
24const GUEST_PREFIX: &str = "constraint_";
25
26/// A Lace-130 exclusion: records carrying a matching `Field` occurrence.
27///
28/// `None` components are the anonymous `_`. The composer appends the negated
29/// atom to every composed selection and exposure facet, and its
30/// `AdvertisedField` counterpart to request selection.
31#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub struct FieldExclusion {
33    pub name: Option<String>,
34    pub index: Option<String>,
35    pub value: Option<String>,
36}
37
38impl FieldExclusion {
39    /// Exclude records carrying any occurrence of field `name`.
40    pub fn named(name: impl Into<String>) -> Self {
41        Self {
42            name: Some(name.into()),
43            ..Self::default()
44        }
45    }
46
47    fn negated(&self, predicate: &str) -> BodyAtom {
48        let term = |component: &Option<String>| {
49            component
50                .as_deref()
51                .map_or(Term::Anonymous, |value| Term::Const(value.into()))
52        };
53        BodyAtom::Negated {
54            predicate: predicate.into(),
55            terms: vec![
56                record_term(),
57                term(&self.name),
58                term(&self.index),
59                term(&self.value),
60            ],
61        }
62    }
63}
64
65/// Compose authoring and guest policies into one canonical operand.
66///
67/// Present selection and exposure facets of every input intersect; absent
68/// facets add no local constraint. Directional veto rules union. Each distinct
69/// module's helpers are isolated, including dependencies under negation,
70/// cardinality, and TopK. Guest record facts and admission order resolve
71/// through the authoring inputs' exposure view. Exclusions subtract records
72/// from every present selection and exposure facet and from that view. This
73/// produces one ordinary operand, not another exchange-plan input. Each class
74/// is order-insensitive and deduplicated; a module in both classes
75/// contributes once to each.
76/// Empty authoring input and helper-only programs are policy errors. Veto-only
77/// policies are valid.
78/// Preparation failures retain their typed category.
79pub fn prepare_facet_constraints(
80    authoring: &[InterlaceModule],
81    guests: &[InterlaceModule],
82    exclusions: &[FieldExclusion],
83) -> Result<InterlaceModule, PreparationError> {
84    fn canonical(modules: &[InterlaceModule]) -> BTreeMap<&str, &InterlaceModule> {
85        modules
86            .iter()
87            .map(|module| (module.canonical_text(), module))
88            .collect()
89    }
90    let (authoring, guests) = (canonical(authoring), canonical(guests));
91    let exclusions = exclusions.iter().collect::<BTreeSet<_>>();
92    let excluded = |predicate: &'static str| {
93        exclusions
94            .iter()
95            .map(move |exclusion| exclusion.negated(predicate))
96    };
97    if authoring.is_empty() {
98        return Err("no authoring facet constraints".into());
99    }
100    if !authoring
101        .values()
102        .chain(guests.values())
103        .all(|module| module.has_facet())
104    {
105        return Err("policy has no executable facets".into());
106    }
107    if authoring.len() == 1 && guests.is_empty() && exclusions.is_empty() {
108        return Ok((*authoring.into_values().next().unwrap()).clone());
109    }
110
111    let mut rules = RuleCollector::new(DatalogLimits::default());
112    let mut constraints: [Vec<BodyAtom>; 3] = Default::default();
113    let mut guest_view = Vec::new();
114    let mut guest_rules = Vec::new();
115    let authoring_count = authoring.len();
116    for (index, module) in authoring
117        .into_values()
118        .chain(guests.into_values())
119        .enumerate()
120    {
121        for (facet_index, facet) in INTERSECTED_FACETS.iter().enumerate() {
122            if module
123                .program()
124                .strata
125                .iter()
126                .flatten()
127                .any(|rule| rule.head_predicate == *facet)
128            {
129                let constraint = BodyAtom::Positive {
130                    predicate: scoped_predicate(facet, index),
131                    terms: vec![record_term()],
132                };
133                if index < authoring_count && *facet == EXPOSE_RECORD.name {
134                    guest_view.push(constraint.clone());
135                }
136                constraints[facet_index].push(constraint);
137            }
138        }
139        for rule in module.program().strata.iter().flatten() {
140            let mut rule = rule.clone();
141            rule.rename_relations(|name| scoped_predicate(name, index));
142            if index < authoring_count {
143                rules.push(rule)?;
144            } else {
145                guest_rules.push(rule);
146            }
147        }
148    }
149    if !guest_rules.is_empty() {
150        let confined = ExposureView {
151            gate: GUEST_VIEW,
152            prefix: GUEST_PREFIX,
153            specialize: false,
154        }
155        .confine(guest_rules)?;
156        let gated = confined.iter().any(|rule| {
157            rule.body
158                .iter()
159                .filter_map(BodyAtom::relation)
160                .any(|(predicate, _, _)| predicate == GUEST_VIEW)
161        });
162        for rule in confined {
163            rules.push(rule)?;
164        }
165        if gated {
166            // Without authoring exposure the view is empty: positive
167            // self-recursion has an empty least fixed point.
168            if guest_view.is_empty() {
169                guest_view.push(BodyAtom::Positive {
170                    predicate: GUEST_VIEW.into(),
171                    terms: vec![record_term()],
172                });
173            } else {
174                guest_view.extend(excluded(FIELD.name));
175            }
176            rules.push(Rule {
177                head_predicate: GUEST_VIEW.into(),
178                head_terms: vec![record_term()],
179                body: guest_view,
180            })?;
181        }
182    }
183    for (facet, mut body) in INTERSECTED_FACETS.into_iter().zip(constraints) {
184        if !body.is_empty() {
185            body.extend(excluded(if facet == SELECT_ADVERTISED_CANDIDATE.name {
186                ADVERTISED_FIELD.name
187            } else {
188                FIELD.name
189            }));
190            rules.push(Rule {
191                head_predicate: facet.into(),
192                head_terms: vec![record_term()],
193                body,
194            })?;
195        }
196    }
197    InterlaceModule::from_rules(rules.into_rules())
198}
199
200fn record_term() -> Term {
201    Term::var("Record")
202}
203
204fn scoped_predicate(name: &str, index: usize) -> Symbol {
205    if let Some(facet) = INTERSECTED_FACETS.iter().position(|facet| *facet == name) {
206        format!("constraint{index}_facet{facet}").into()
207    } else if name.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
208        format!("constraint{index}_helper_{name}").into()
209    } else {
210        // Base facts stay shared. Output-only vetoes union without wrappers.
211        name.into()
212    }
213}
214
215#[cfg(test)]
216mod exclusion_tests;
217#[cfg(test)]
218mod guest_tests;
219#[cfg(test)]
220mod tests;