Skip to main content

memstead_base/ops/
labelling.rs

1//! Grounded labelling over a schema-declared attack set — the one
2//! argumentation-semantics computation that is parameter-free,
3//! unique, polynomial, and explainable by construction: unattacked
4//! entities are `accepted`, whatever an accepted entity attacks is
5//! `defeated`, entities whose attackers are all defeated are
6//! `accepted`, the rest stay `undecided`.
7//!
8//! A label is a reported observation with its evidence — never a
9//! stored value, never a write gate, never a status. The labelling is
10//! deliberately support-blind: it walks attack edges only, and a
11//! defeated supporter never flips what it supports; the chain-shape
12//! statistics give the reader that fact as a count instead.
13
14use std::collections::{BTreeMap, HashMap};
15
16use crate::entity::EntityId;
17use crate::store::Store;
18use memstead_schema::{LabellingDef, ReachDirection, SupportWalk};
19
20/// The grounded label of one entity.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Label {
23    Accepted,
24    Defeated,
25    Undecided,
26}
27
28impl Label {
29    pub fn wire(&self) -> &'static str {
30        match self {
31            Label::Accepted => "accepted",
32            Label::Defeated => "defeated",
33            Label::Undecided => "undecided",
34        }
35    }
36}
37
38/// One mem's grounded labelling — the least fixpoint over the pinned
39/// graph (non-stub entities of the mem; attack-set edges whose
40/// endpoints are both non-stub nodes of the mem). Deterministic:
41/// BTreeMaps keyed by id string.
42#[derive(Debug, Clone)]
43pub struct MemLabelling {
44    /// Label per non-stub entity id of the mem.
45    pub labels: BTreeMap<String, Label>,
46    /// Direct in-mem attackers per entity id, sorted.
47    pub attackers: BTreeMap<String, Vec<String>>,
48    /// Attack-set edges incident to this mem's nodes whose other
49    /// endpoint lives in another mem — excluded from the computation
50    /// and counted, never guessed.
51    pub cross_mem_edges_excluded: usize,
52}
53
54impl MemLabelling {
55    /// The accepted direct attackers of `id` — the evidence a
56    /// `defeated` label always carries.
57    pub fn accepted_attackers_of(&self, id: &str) -> Vec<String> {
58        self.direct_attackers_with(id, Label::Accepted)
59    }
60
61    /// The undecided direct attackers of `id` — the open attacker set
62    /// that keeps an `undecided` label open.
63    pub fn undecided_attackers_of(&self, id: &str) -> Vec<String> {
64        self.direct_attackers_with(id, Label::Undecided)
65    }
66
67    fn direct_attackers_with(&self, id: &str, label: Label) -> Vec<String> {
68        self.attackers
69            .get(id)
70            .map(|atts| {
71                atts.iter()
72                    .filter(|a| self.labels.get(a.as_str()) == Some(&label))
73                    .cloned()
74                    .collect()
75            })
76            .unwrap_or_default()
77    }
78}
79
80/// Compute one mem's grounded labelling over the declared attack set.
81pub fn compute_mem_labelling(store: &Store, mem: &str, attack: &[String]) -> MemLabelling {
82    // The pinned node set: every non-stub entity of the mem.
83    let mut node_ids: Vec<String> = store
84        .all_entities()
85        .filter(|e| e.mem == mem && !e.stub)
86        .map(|e| e.id.0.clone())
87        .collect();
88    node_ids.sort();
89    let node_set: std::collections::HashSet<&str> = node_ids.iter().map(String::as_str).collect();
90
91    // Direct attackers per node (attack edges INTO the node), and the
92    // cross-mem exclusion count over incident attack edges in both
93    // directions. Stub endpoints drop the edge silently (a stub has
94    // no mem-internal standing); cross-mem endpoints are counted.
95    let mut attackers: BTreeMap<String, Vec<String>> = BTreeMap::new();
96    let mut cross_mem_edges_excluded = 0usize;
97    for id_str in &node_ids {
98        let id = EntityId(id_str.clone());
99        let mut atts: Vec<String> = Vec::new();
100        for edge in store.incoming(&id) {
101            if !attack.iter().any(|n| n == &edge.rel_type) {
102                continue;
103            }
104            if edge.from.mem() != mem {
105                cross_mem_edges_excluded += 1;
106                continue;
107            }
108            if node_set.contains(edge.from.0.as_str()) {
109                atts.push(edge.from.0.clone());
110            }
111        }
112        for edge in store.outgoing(&id) {
113            if !attack.iter().any(|n| n == &edge.rel_type) {
114                continue;
115            }
116            if edge.target.mem() != mem {
117                cross_mem_edges_excluded += 1;
118            }
119        }
120        atts.sort();
121        atts.dedup();
122        attackers.insert(id_str.clone(), atts);
123    }
124
125    // Least fixpoint: a node whose attackers are all Defeated becomes
126    // Accepted (vacuously true for unattacked nodes); a node with an
127    // Accepted attacker becomes Defeated; iterate to fixpoint; the
128    // rest stay Undecided.
129    let mut labels: HashMap<&str, Label> = HashMap::new();
130    loop {
131        let mut changed = false;
132        for id in &node_ids {
133            if labels.contains_key(id.as_str()) {
134                continue;
135            }
136            let atts = &attackers[id.as_str()];
137            if atts
138                .iter()
139                .all(|a| labels.get(a.as_str()) == Some(&Label::Defeated))
140            {
141                labels.insert(id.as_str(), Label::Accepted);
142                changed = true;
143            } else if atts
144                .iter()
145                .any(|a| labels.get(a.as_str()) == Some(&Label::Accepted))
146            {
147                labels.insert(id.as_str(), Label::Defeated);
148                changed = true;
149            }
150        }
151        if !changed {
152            break;
153        }
154    }
155
156    let labels: BTreeMap<String, Label> = node_ids
157        .iter()
158        .map(|id| {
159            (
160                id.clone(),
161                labels.get(id.as_str()).copied().unwrap_or(Label::Undecided),
162            )
163        })
164        .collect();
165
166    MemLabelling {
167        labels,
168        attackers,
169        cross_mem_edges_excluded,
170    }
171}
172
173/// The chain-shape statistics over one entity's support subtree —
174/// the adversarial-shape indicators (an unusually deep chain with no
175/// failing leaves warrants scrutiny). The engine serves numbers, the
176/// reader judges.
177#[derive(Debug, Clone, PartialEq)]
178pub struct ShapeStats {
179    /// Longest observed level of the visited-set-bounded breadth-first
180    /// walk (exact longest path on tree-shaped support).
181    pub depth: u64,
182    /// Mean number of support successors over the walked nodes that
183    /// have any (0.0 when none do).
184    pub branching: f64,
185    /// Leaves of a terminal type over all leaves — `None` when the
186    /// subtree has no leaves (an isolated entity, or a pure cycle).
187    pub terminal_share: Option<f64>,
188    /// Subtree nodes (excluding the entity) labelled `defeated` by
189    /// their own mem's labelling.
190    pub defeated_in_support: u64,
191    /// Subtree nodes (excluding the entity) labelled `undecided`.
192    pub undecided_in_support: u64,
193}
194
195/// Walk the support subtree from `start` and compute the shape
196/// statistics. `label_of` resolves a subtree node's label (nodes of
197/// mems without a labelling declaration resolve to `None` and count
198/// toward neither label count).
199pub fn compute_shape(
200    store: &Store,
201    start: &EntityId,
202    walk: &SupportWalk,
203    label_of: &dyn Fn(&EntityId) -> Option<Label>,
204) -> ShapeStats {
205    let successors = |id: &EntityId| -> Vec<EntityId> {
206        let mut next: Vec<EntityId> = match walk.direction {
207            ReachDirection::Out => store
208                .outgoing(id)
209                .iter()
210                .filter(|e| walk.relationships.iter().any(|n| n == &e.rel_type))
211                .map(|e| e.target.clone())
212                .collect(),
213            ReachDirection::In => store
214                .incoming(id)
215                .iter()
216                .filter(|e| walk.relationships.iter().any(|n| n == &e.rel_type))
217                .map(|e| e.from.clone())
218                .collect(),
219        };
220        next.sort_by(|a, b| a.0.cmp(&b.0));
221        next.dedup();
222        next
223    };
224
225    // Visited-set-bounded BFS from the start; the subtree is every
226    // node reached (the start excluded from all counts).
227    let mut visited: std::collections::HashSet<EntityId> = std::iter::once(start.clone()).collect();
228    let mut frontier = vec![start.clone()];
229    let mut depth: u64 = 0;
230    let mut subtree: Vec<EntityId> = Vec::new();
231    let mut successor_counts: Vec<usize> = Vec::new();
232    // The start's own successor count participates in branching.
233    let start_succ = successors(start).len();
234    if start_succ > 0 {
235        successor_counts.push(start_succ);
236    }
237    while !frontier.is_empty() {
238        let mut next_frontier = Vec::new();
239        for current in frontier {
240            for next in successors(&current) {
241                if visited.insert(next.clone()) {
242                    subtree.push(next.clone());
243                    next_frontier.push(next);
244                }
245            }
246        }
247        if !next_frontier.is_empty() {
248            depth += 1;
249        }
250        frontier = next_frontier;
251    }
252
253    let mut leaves_total = 0u64;
254    let mut leaves_terminal = 0u64;
255    let mut defeated_in_support = 0u64;
256    let mut undecided_in_support = 0u64;
257    for node in &subtree {
258        let succ = successors(node);
259        if succ.is_empty() {
260            leaves_total += 1;
261            if store
262                .get(node)
263                .is_some_and(|e| !e.stub && walk.terminal_types.iter().any(|t| t == &e.entity_type))
264            {
265                leaves_terminal += 1;
266            }
267        } else {
268            successor_counts.push(succ.len());
269        }
270        match label_of(node) {
271            Some(Label::Defeated) => defeated_in_support += 1,
272            Some(Label::Undecided) => undecided_in_support += 1,
273            _ => {}
274        }
275    }
276
277    let branching = if successor_counts.is_empty() {
278        0.0
279    } else {
280        successor_counts.iter().sum::<usize>() as f64 / successor_counts.len() as f64
281    };
282    let terminal_share = if leaves_total == 0 {
283        None
284    } else {
285        Some(leaves_terminal as f64 / leaves_total as f64)
286    };
287
288    ShapeStats {
289        depth,
290        branching,
291        terminal_share,
292        defeated_in_support,
293        undecided_in_support,
294    }
295}
296
297/// One entity's served labelling view — label, evidence, and the
298/// optional shape block.
299#[derive(Debug, Clone)]
300pub struct LabellingView {
301    pub label: Label,
302    pub defeated_by: Vec<String>,
303    pub undecided_by: Vec<String>,
304    pub shape: Option<ShapeStats>,
305}
306
307impl LabellingView {
308    /// The structured-envelope form:
309    /// `{label, defeated_by, undecided_by, shape?}`.
310    pub fn to_json(&self) -> serde_json::Value {
311        let mut v = serde_json::json!({
312            "label": self.label.wire(),
313            "defeated_by": self.defeated_by,
314            "undecided_by": self.undecided_by,
315        });
316        if let Some(shape) = &self.shape {
317            v["shape"] = serde_json::json!({
318                "depth": shape.depth,
319                "branching": shape.branching,
320                "terminal_share": shape.terminal_share,
321                "defeated_in_support": shape.defeated_in_support,
322                "undecided_in_support": shape.undecided_in_support,
323            });
324        }
325        v
326    }
327}
328
329/// Convenience: whether a schema's manifest declares labelling.
330pub fn labelling_of(schema: &memstead_schema::Schema) -> Option<&LabellingDef> {
331    schema.manifest.relationships.labelling.as_ref()
332}