Skip to content

Commit 7c1cde4

Browse files
committed
feat(contract+ontology): D-ODOO-BP-1a typed OdooEntity surface + D-ODOO-SAV-5b 25 savant role keys
Two parallel additive deliverables — both zero-churn surfaces opened by the v2 reshape (PR #420 deprecation) + blueprint plan (commits 741a25c + 6d2466e): ## D-ODOO-BP-1a — typed Odoo entity DTOs (lance-graph-ontology::odoo_blueprint) New module `crates/lance-graph-ontology/src/odoo_blueprint.rs` carrying the typed surface the OGIT → OWL → DOLCE → FIBU/FIBO inheritance chain will operate on (replacing today's ad-hoc string-keyed maps against `model_name`). Types: OdooEntity + OdooField + OdooMethod + OdooDecorator + OdooStateMachine + OdooState + OdooTransition + OdooConstraint + OdooProvenance + OdooSourceRef. Enums: OdooFieldKind (17 variants), OdooSemanticRole (12), OdooMethodKind (10), OdooReturnKind (10), OdooDecoratorKind (7), OdooStateSemantic (7), OdooConstraintKind (3), OdooConfidence (3 — Curated/Extracted/Conjecture). Tests: 3 — sample_entity_compiles_as_const (FiscalPosition shape), state_machine_entity_compiles (Invoice draft→posted→cancel), empty_entity_compiles (zero case). Per-lane consts (L1–L15) land in D-ODOO-BP-1b; OGIT/OWL/DOLCE/FIBU wiring lands in 1c/d/e; source extraction in 1f; JITson + recipes in 1g. ## D-ODOO-SAV-5b — 25 savant role keys (contract::callcenter::role_keys) New module `crates/lance-graph-contract/src/callcenter/{mod,role_keys}.rs` per I-VSA-IDENTITIES Layer-2 catalogue doctrine (sibling of `contract::grammar::role_keys`, NOT in the separate lance-graph-callcenter crate — path correction from the v2 plan). Slice layout: 25 savants × 90 dims = 2250 dims in the SMB headroom [14096..16346), with 38 dims of headroom remaining. FNV-64 seeded from each savant's name (lookup matches SAVANTS const order). LazyLock<[RoleKey; 25]> lookup by id or name. Tests: 7 — slices_disjoint_and_in_bounds, savant_zone_fits_in_smb_headroom (2250 dims + 38 headroom = 2288), id_lookup_matches_name_lookup, id_16_absent (the intentional gap), last_savant_is_backorder_judge, deterministic_pseudo_random_bits, no_overlap_with_grammar_slices. ## Supporting change `RoleKey::generate` made `pub` in `contract::grammar::role_keys` so sibling per-domain Layer-2 catalogues (callcenter today; persona, others later) can construct their own role keys with disjoint slice allocations — per I-VSA-IDENTITIES. Documented in the doc-comment. ## Tests + integration - lance-graph-contract: 7 new callcenter::role_keys tests pass; 454 prior contract lib tests unaffected - lance-graph-ontology: 3 new odoo_blueprint tests pass; 43 lib tests total ## Followups - D-ODOO-SAV-5a (BusinessGrammarTemplate primitive) — design pass complete (4-surface integration map: DeepNSM SpoTriple shape + ractor ModuleEntry + AriGraph SpoRecord/TruthValue + ReasoningWitness64). Code lands next, with the 7-field shape: PatternMatchSpec + AtomTouchMask + MailboxSpawnSpec + RecipeComposition + EdgeEmissionSpec + EpisodicWitnessSpec + TemplateProvenance. - D-ODOO-BP-1b — per-lane entity consts, L1–L15 Wave (one subagent per lane). https://claude.ai/code/session_017gZ6sPRXYPj5n7uJ7NBtRv
1 parent 9ff4682 commit 7c1cde4

6 files changed

Lines changed: 586 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//! callcenter-domain Layer-2 catalogue (per `I-VSA-IDENTITIES`).
2+
//!
3+
//! Sibling of [`crate::grammar::role_keys`] and the future
4+
//! `persona::role_keys`: one identity fingerprint per concept, with
5+
//! disjoint slice allocations. The 25 Odoo savants from
6+
//! [`crate::savants::SAVANTS`] land here as the first set of
7+
//! callcenter-domain identities.
8+
//!
9+
//! See `.claude/knowledge/vsa-switchboard-architecture.md` for the
10+
//! three-layer Layer-2 catalogue doctrine and
11+
//! `.claude/plans/odoo-savant-reasoners-v2.md` for the broader
12+
//! composition-over-substrate reshape this module participates in.
13+
14+
pub mod role_keys;
15+
16+
pub use role_keys::{
17+
savant_role_key, savant_role_key_by_name, SAVANT_ROLE_KEYS, SAVANT_SLICE_END,
18+
SAVANT_SLICE_START, SAVANT_SLICE_WIDTH,
19+
};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//! Savant role-key catalogue — 25 disjoint [`RoleKey`] slices, one
2+
//! identity per Odoo savant in [`crate::savants::SAVANTS`].
3+
//!
4+
//! Lands in the SMB headroom `[14096..16384)` that
5+
//! [`crate::grammar::role_keys`] reserves for future SMB keys (per the
6+
//! LF-2 16K resize). Each savant gets 90 dims of pseudo-random bipolar
7+
//! identity, FNV-64-seeded from the savant's name; disjoint by
8+
//! construction.
9+
//!
10+
//! ## Layout
11+
//!
12+
//! ```text
13+
//! [14096 .. 14186) savant 1 (FiscalPositionResolver)
14+
//! [14186 .. 14276) savant 2 (PartnerTrustAdvisor)
15+
//! [14276 .. 14366) savant 3 (PricelistAssignmentAgent)
16+
//! ...
17+
//! [16256 .. 16346) savant 25 (BackorderJudge, roster id 26 — id 16 absent)
18+
//! [16346 .. 16384) 38 dims headroom (reserved for future callcenter keys)
19+
//! ```
20+
//!
21+
//! Total footprint: 25 × 90 = 2250 dims, within the 2288-dim SMB
22+
//! headroom (`grammar::role_keys::VSA_DIMS = 16_384` minus STEUER_KEY's
23+
//! end at `14_096`).
24+
//!
25+
//! `D-ODOO-SAV-5b` of `odoo-savant-reasoners-v2`.
26+
27+
use std::sync::LazyLock;
28+
29+
use crate::grammar::role_keys::RoleKey;
30+
use crate::savants::{savant, savant_by_name, Savant, SAVANTS};
31+
32+
/// Start of the savant identity zone — directly after STEUER_KEY's
33+
/// `[13584..14096)` slice in [`crate::grammar::role_keys`].
34+
pub const SAVANT_SLICE_START: usize = 14_096;
35+
36+
/// One identity slice per savant — 90 dims of FNV-seeded pseudo-random
37+
/// bits. 25 × 90 = 2250 dims < the 2288-dim SMB headroom.
38+
pub const SAVANT_SLICE_WIDTH: usize = 90;
39+
40+
/// End of the savant identity zone: `SAVANT_SLICE_START + 25 *
41+
/// SAVANT_SLICE_WIDTH = 16_346`. 38 dims of headroom remain.
42+
pub const SAVANT_SLICE_END: usize = SAVANT_SLICE_START + 25 * SAVANT_SLICE_WIDTH;
43+
44+
/// The 25 savant role keys in roster order (same order as
45+
/// [`crate::savants::SAVANTS`]).
46+
///
47+
/// Indexing matches roster index `i` (NOT savant `id`; roster id 16 is
48+
/// intentionally absent per `SAVANTS.md`, so use the lookup helpers
49+
/// [`savant_role_key`] / [`savant_role_key_by_name`] rather than direct
50+
/// indexing).
51+
pub static SAVANT_ROLE_KEYS: LazyLock<[RoleKey; 25]> = LazyLock::new(|| {
52+
core::array::from_fn(|i| {
53+
let s: &Savant = &SAVANTS[i];
54+
let start = SAVANT_SLICE_START + i * SAVANT_SLICE_WIDTH;
55+
let end = start + SAVANT_SLICE_WIDTH;
56+
RoleKey::generate(s.name, start, end)
57+
})
58+
});
59+
60+
/// Look up a savant's role key by roster id (1..=15, 17..=26; id 16
61+
/// intentionally absent).
62+
///
63+
/// Returns `None` if the id does not appear in [`SAVANTS`].
64+
pub fn savant_role_key(id: u8) -> Option<&'static RoleKey> {
65+
let _ = savant(id)?;
66+
SAVANTS
67+
.iter()
68+
.position(|s| s.id == id)
69+
.map(|i| &SAVANT_ROLE_KEYS[i])
70+
}
71+
72+
/// Look up a savant's role key by name (the `SAVANTS[i].name`).
73+
///
74+
/// Returns `None` if the name does not appear in [`SAVANTS`].
75+
pub fn savant_role_key_by_name(name: &str) -> Option<&'static RoleKey> {
76+
let _ = savant_by_name(name)?;
77+
SAVANTS
78+
.iter()
79+
.position(|s| s.name == name)
80+
.map(|i| &SAVANT_ROLE_KEYS[i])
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use crate::grammar::role_keys::VSA_DIMS;
87+
88+
#[test]
89+
fn slices_disjoint_and_in_bounds() {
90+
for (i, key) in SAVANT_ROLE_KEYS.iter().enumerate() {
91+
let expected_start = SAVANT_SLICE_START + i * SAVANT_SLICE_WIDTH;
92+
let expected_end = expected_start + SAVANT_SLICE_WIDTH;
93+
assert_eq!(key.slice_start, expected_start, "savant index {i}");
94+
assert_eq!(key.slice_end, expected_end, "savant index {i}");
95+
assert_eq!(key.slice_width(), SAVANT_SLICE_WIDTH);
96+
assert!(key.slice_end <= VSA_DIMS, "savant {i} fits VSA space");
97+
}
98+
}
99+
100+
#[test]
101+
fn savant_zone_fits_in_smb_headroom() {
102+
// grammar::role_keys ends at SMB STEUER_KEY = 14_096 and reserves
103+
// [14_096 .. 16_384) (= 2288 dims) as headroom. We claim 2250 of
104+
// those 2288 dims; 38 remain.
105+
assert_eq!(SAVANT_SLICE_END, 16_346);
106+
assert!(SAVANT_SLICE_END <= VSA_DIMS);
107+
assert_eq!(VSA_DIMS - SAVANT_SLICE_END, 38, "headroom remaining");
108+
}
109+
110+
#[test]
111+
fn id_lookup_matches_name_lookup() {
112+
// FiscalPositionResolver is savant id 1, roster index 0.
113+
let by_id = savant_role_key(1).expect("id 1");
114+
let by_name = savant_role_key_by_name("FiscalPositionResolver").expect("name");
115+
assert_eq!(by_id.label, by_name.label);
116+
assert_eq!(by_id.slice_start, by_name.slice_start);
117+
assert_eq!(by_id.slice_start, SAVANT_SLICE_START, "first roster slot");
118+
}
119+
120+
#[test]
121+
fn id_16_absent() {
122+
// SAVANTS.md skips roster id 16. Lookup must return None.
123+
assert!(savant_role_key(16).is_none());
124+
}
125+
126+
#[test]
127+
fn last_savant_is_backorder_judge() {
128+
// BackorderJudge has roster id 26 and lives at roster index 24
129+
// (the 25th and final savant).
130+
let key = savant_role_key(26).expect("id 26");
131+
assert_eq!(key.label, "BackorderJudge");
132+
assert_eq!(key.slice_start, SAVANT_SLICE_START + 24 * SAVANT_SLICE_WIDTH);
133+
assert_eq!(key.slice_end, SAVANT_SLICE_END);
134+
}
135+
136+
#[test]
137+
fn deterministic_pseudo_random_bits() {
138+
// FNV-64-seeded from the savant's name: same name → same bits.
139+
// 90-dim slice: roughly half the bits should be set (the LCG is
140+
// unbiased over a 90-bit window).
141+
let key = savant_role_key_by_name("FiscalPositionResolver").unwrap();
142+
let total_set: u32 = key.words.iter().map(|w| w.count_ones()).sum();
143+
assert!(total_set > 20, "some bits set in 90-dim slice: {total_set}");
144+
assert!(total_set < 80, "some bits clear in 90-dim slice: {total_set}");
145+
}
146+
147+
#[test]
148+
fn no_overlap_with_grammar_slices() {
149+
// grammar::role_keys SMB keys end at STEUER_KEY's `14_096`; savants
150+
// start at `14_096`. SPO core roles and TEKAMOLO slots all sit
151+
// below `14_096`, so no overlap by construction.
152+
use crate::grammar::role_keys::{OBJECT_KEY, PREDICATE_KEY, SUBJECT_KEY};
153+
assert!(SUBJECT_KEY.slice_end <= SAVANT_SLICE_START);
154+
assert!(PREDICATE_KEY.slice_end <= SAVANT_SLICE_START);
155+
assert!(OBJECT_KEY.slice_end <= SAVANT_SLICE_START);
156+
}
157+
}

crates/lance-graph-contract/src/grammar/role_keys.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,13 @@ impl RoleKey {
9191

9292
/// Generate a deterministic role key: pseudo-random bits in `[start..end)`,
9393
/// zeros everywhere else. Seeded from FNV-64 of the label.
94-
fn generate(label: &'static str, start: usize, end: usize) -> Self {
94+
///
95+
/// `pub` so per-domain Layer-2 catalogues (sibling modules under this
96+
/// crate — `grammar`, `callcenter`, future `persona`) can construct
97+
/// their own role keys with disjoint slice allocations. Per
98+
/// `I-VSA-IDENTITIES`: identity in the role-key catalogue, content in
99+
/// downstream registries.
100+
pub fn generate(label: &'static str, start: usize, end: usize) -> Self {
95101
debug_assert!(start <= end);
96102
debug_assert!(end <= VSA_DIMS);
97103
let mut words = Box::new([0u64; VSA_WORDS]);

crates/lance-graph-contract/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
pub mod a2a_blackboard;
3838
pub mod atoms;
3939
pub mod auth;
40+
pub mod callcenter;
4041
pub mod cam;
4142
pub mod cognitive_shader;
4243
pub mod collapse_gate;

crates/lance-graph-ontology/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub mod foundry_map;
4242
pub mod hydrators;
4343
pub mod namespace;
4444
pub mod namespace_registry;
45+
pub mod odoo_blueprint;
4546
pub mod proposal;
4647
pub mod registry;
4748
pub mod schema_source;

0 commit comments

Comments
 (0)