Skip to content

Commit 1d4c22c

Browse files
authored
Merge pull request #179 from AdaWorldAPI/claude/mint-armb-ttl
§6-Mint: ogar:Constrains/Onchange — TTL-Registry + Emitter-Arme in Lockstep (5+3-Council GO)
2 parents 90ba79f + 1358d50 commit 1d4c22c

3 files changed

Lines changed: 177 additions & 20 deletions

File tree

crates/ogar-emitter/src/lib.rs

Lines changed: 142 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,48 @@ fn kausal_triples(action_subject: &str, k: &ogar_vocab::KausalSpec) -> Vec<Tripl
847847
}
848848
v
849849
}
850+
// `@api.constrains` / `@api.onchange` (SPEC-ATC2-OGAR Arm B) — kept
851+
// kausal-scoped (ogar:kausalConstrainsPath / ogar:kausalOnchangePath),
852+
// never the ComputedField predicates, for the same reason as Depends
853+
// above. Odoo silently drops dotted paths in `@api.constrains` /
854+
// `@api.onchange` (odoo/orm/decorators.py:106-108/213-215); mirror
855+
// that here: skip the path triple, keep the kausalKind triple.
856+
KausalSpec::Constrains { paths } => {
857+
let mut v = vec![Triple::new(
858+
action_subject,
859+
"ogar:kausalKind",
860+
"ogar:Constrains",
861+
)];
862+
for p in paths {
863+
if p.contains('.') {
864+
continue;
865+
}
866+
v.push(Triple::new(
867+
action_subject,
868+
"ogar:kausalConstrainsPath",
869+
p.clone(),
870+
));
871+
}
872+
v
873+
}
874+
KausalSpec::Onchange { paths } => {
875+
let mut v = vec![Triple::new(
876+
action_subject,
877+
"ogar:kausalKind",
878+
"ogar:Onchange",
879+
)];
880+
for p in paths {
881+
if p.contains('.') {
882+
continue;
883+
}
884+
v.push(Triple::new(
885+
action_subject,
886+
"ogar:kausalOnchangePath",
887+
p.clone(),
888+
));
889+
}
890+
v
891+
}
850892
KausalSpec::ContextDepends { keys } => {
851893
let mut v = vec![Triple::new(
852894
action_subject,
@@ -1697,39 +1739,119 @@ mod tests {
16971739
t.iter()
16981740
.any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:External")
16991741
);
1742+
1743+
def.kausal = Some(ogar_vocab::KausalSpec::constrains(vec!["state".into()]));
1744+
let t = TripleEmitter::emit_action_def(&def);
1745+
assert!(
1746+
t.iter()
1747+
.any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Constrains")
1748+
);
1749+
assert!(
1750+
t.iter()
1751+
.any(|t| t.predicate == "ogar:kausalConstrainsPath" && t.object == "state")
1752+
);
1753+
1754+
def.kausal = Some(ogar_vocab::KausalSpec::onchange(vec!["partner_id".into()]));
1755+
let t = TripleEmitter::emit_action_def(&def);
1756+
assert!(
1757+
t.iter()
1758+
.any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Onchange")
1759+
);
1760+
assert!(
1761+
t.iter()
1762+
.any(|t| t.predicate == "ogar:kausalOnchangePath" && t.object == "partner_id")
1763+
);
17001764
}
17011765

17021766
#[test]
1703-
fn kausal_constrains_onchange_currently_emit_unknown_pending_emitter_wiring() {
1704-
// CHARACTERIZATION (not aspiration): SPEC-ATC2-OGAR §6 defers the TTL
1705-
// emitter wiring for the Arm B variants, so `kausal_triples` has no
1706-
// Constrains/Onchange arm — they fall through the mandatory wildcard
1707-
// (`KausalSpec` is #[non_exhaustive], so cross-crate matches CANNOT be
1708-
// wildcard-free; the `kausal_spec_match_is_exhaustive` fuse only
1709-
// protects ogar-vocab, never this consumer). The IR struct carries the
1710-
// paths correctly; the TTL projection currently drops them and labels
1711-
// the kind `ogar:Unknown`. This test PINS that lossy interim so the
1712-
// drop is documented, not silent — when §6 lands (predicates
1713-
// `ogar:Constrains`/`ogar:Onchange` + a path predicate), this test
1714-
// fails loudly and forces the implementer to update it. See ledger
1715-
// D-ATC2-KAUSAL-RUFF-GATED.
1716-
for k in [
1717-
ogar_vocab::KausalSpec::constrains(vec!["state".into()]),
1718-
ogar_vocab::KausalSpec::onchange(vec!["partner_id".into()]),
1767+
fn kausal_constrains_onchange_emit_declared_kinds_and_paths() {
1768+
// §6-Mint (SPEC-MINT-ARM-B-TTL): Constrains/Onchange now emit their
1769+
// own declared kausalKind + kausal-scoped path predicates, replacing
1770+
// the prior ogar:Unknown characterization (D-ATC2-KAUSAL-RUFF-GATED,
1771+
// debt resolved per 5+3-Council GO).
1772+
for (k, expected_kind, expected_path_predicate, path) in [
1773+
(
1774+
ogar_vocab::KausalSpec::constrains(vec!["state".into()]),
1775+
"ogar:Constrains",
1776+
"ogar:kausalConstrainsPath",
1777+
"state",
1778+
),
1779+
(
1780+
ogar_vocab::KausalSpec::onchange(vec!["partner_id".into()]),
1781+
"ogar:Onchange",
1782+
"ogar:kausalOnchangePath",
1783+
"partner_id",
1784+
),
17191785
] {
17201786
let mut def = ogar_vocab::ActionDef::new("a", "p", "ogit-op/Foo");
17211787
def.kausal = Some(k);
17221788
let t = TripleEmitter::emit_action_def(&def);
1789+
1790+
assert!(
1791+
t.iter()
1792+
.any(|t| t.predicate == "ogar:kausalKind" && t.object == expected_kind),
1793+
"expected kausalKind {expected_kind}"
1794+
);
17231795
assert!(
17241796
t.iter()
1797+
.any(|t| t.predicate == expected_path_predicate && t.object == path),
1798+
"expected {expected_path_predicate} triple for {path}"
1799+
);
1800+
1801+
// Negative guards (Konflations-Fuse, Codex-Regel 2026-06-04):
1802+
// Arm B must never fall back to Unknown, and must never reuse
1803+
// the ComputedField or Depends path predicates.
1804+
assert!(
1805+
!t.iter()
17251806
.any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Unknown"),
1726-
"interim: unwired Arm B variants label as ogar:Unknown"
1807+
"Arm B variants must not label as ogar:Unknown"
1808+
);
1809+
assert!(
1810+
!t.iter().any(|t| t.predicate == "ogar:dependsPath"),
1811+
"Arm B variants must not emit the ComputedField ogar:dependsPath"
1812+
);
1813+
assert!(
1814+
!t.iter().any(|t| t.predicate == "ogar:kausalDependsPath"),
1815+
"Arm B variants must not emit the Depends-scoped ogar:kausalDependsPath"
1816+
);
1817+
}
1818+
}
1819+
1820+
#[test]
1821+
fn kausal_constrains_onchange_drop_dotted_paths_without_triple() {
1822+
// Council-S5 pathology: Odoo silently ignores dotted paths in
1823+
// `@api.constrains` / `@api.onchange` (odoo/orm/decorators.py:
1824+
// 106-108/213-215). Mirror that: drop-with-no-triple for the dotted
1825+
// path, but the kausalKind triple still stands — NOT ogar:Unknown.
1826+
for (k, expected_kind, expected_path_predicate) in [
1827+
(
1828+
ogar_vocab::KausalSpec::constrains(vec!["partner_id.name".into()]),
1829+
"ogar:Constrains",
1830+
"ogar:kausalConstrainsPath",
1831+
),
1832+
(
1833+
ogar_vocab::KausalSpec::onchange(vec!["partner_id.name".into()]),
1834+
"ogar:Onchange",
1835+
"ogar:kausalOnchangePath",
1836+
),
1837+
] {
1838+
let mut def = ogar_vocab::ActionDef::new("a", "p", "ogit-op/Foo");
1839+
def.kausal = Some(k);
1840+
let t = TripleEmitter::emit_action_def(&def);
1841+
1842+
assert!(
1843+
t.iter()
1844+
.any(|t| t.predicate == "ogar:kausalKind" && t.object == expected_kind),
1845+
"kausalKind triple must remain even when the only path is dotted"
1846+
);
1847+
assert!(
1848+
!t.iter().any(|t| t.predicate == expected_path_predicate),
1849+
"dotted path must be dropped, not emitted as a path triple"
17271850
);
1728-
// The field paths are NOT projected to TTL yet (the debt).
17291851
assert!(
17301852
!t.iter()
1731-
.any(|t| t.object == "state" || t.object == "partner_id"),
1732-
"interim: Arm B field paths are dropped at TTL emission (§6 deferred)"
1853+
.any(|t| t.predicate == "ogar:kausalKind" && t.object == "ogar:Unknown"),
1854+
"dropped dotted path must not reclassify the kind as ogar:Unknown"
17331855
);
17341856
}
17351857
}

docs/DISCOVERY-MAP.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,3 +1125,33 @@ isolation. The map's job is to keep them visible.
11251125
+ a kausal path predicate — a governed vocab mint, NOT done here).
11261126
- Additive API gap closed: `KausalSpec::{constrains,onchange}` constructors
11271127
added to mirror `depends()`/`lifecycle()` (consumers + the test need them).
1128+
- **DEBT RESOLVED (§6-Mint, 2026-07-07, 5+3-Council GO;
1129+
SPEC-MINT-ARM-B-TTL).** The P2 above is closed: `ogar-emitter::
1130+
kausal_triples` now carries `KausalSpec::{Constrains,Onchange}` arms
1131+
emitting `ogar:Constrains` / `ogar:Onchange` + `ogar:kausalConstrainsPath`
1132+
/ `ogar:kausalOnchangePath` per field path (the `_ => ogar:Unknown`
1133+
wildcard still stands, now covering only genuine future variants).
1134+
`vocab/ogar.ttl` carries the matching registry in lockstep (two new
1135+
`ogar:KausalKind` instances + two new `rdf:Property` path predicates,
1136+
same pattern as the five pre-existing instances) — the Council-S3
1137+
"no separate registry" correction from this same entry, now actually
1138+
closed. Council-B1 nebenbefund folded in: `ogar:Unknown` is now ALSO
1139+
declared `a ogar:KausalKind` (it was only ever `a ogar:EnumSourceKind`;
1140+
the wildcard fallback was emitting an undeclared kind IRI all along —
1141+
a pre-existing declaration gap, not new behaviour). Dotted paths
1142+
(Council-S5: Odoo silently drops them in `@api.constrains` /
1143+
`@api.onchange`, `odoo/orm/decorators.py:106-108/213-215`) are
1144+
drop-with-no-triple for that path; the `kausalKind` triple still
1145+
stands, NOT `ogar:Unknown`. The characterization test
1146+
`kausal_constrains_onchange_currently_emit_unknown_pending_emitter_wiring`
1147+
flipped as designed — replaced by
1148+
`kausal_constrains_onchange_emit_declared_kinds_and_paths` (positive
1149+
kausalKind + path assertions, plus the Konflations-Fuse negative
1150+
guards: no `ogar:Unknown`, no `ogar:dependsPath`, no
1151+
`ogar:kausalDependsPath`) and
1152+
`kausal_constrains_onchange_drop_dotted_paths_without_triple`;
1153+
`kausal_spec_variants_emit_distinct_kinds` extended to cover both
1154+
variants. Roundtrip: `ogar-adapter-ttl` has no kausal-consumer/parser
1155+
(verified — its module doc lists `ActionDef` / `KausalSpec` under
1156+
"Not yet supported"), so TTL stays write-only for kausal; no
1157+
roundtrip case added, per the spec's documented fallback.

vocab/ogar.ttl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ ogar:LifecycleTrigger a ogar:KausalKind .
303303
ogar:Depends a ogar:KausalKind .
304304
ogar:ContextDepends a ogar:KausalKind .
305305
ogar:External a ogar:KausalKind .
306+
ogar:Constrains a ogar:KausalKind .
307+
ogar:Onchange a ogar:KausalKind .
308+
ogar:Unknown a ogar:KausalKind .
306309
ogar:guardField a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
307310
ogar:guardValue a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
308311
ogar:triggerEvent a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
@@ -311,6 +314,8 @@ ogar:triggerEvent a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range
311314
# action is NOT inferred as a ComputedField. Codex review.
312315
ogar:kausalDependsPath a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
313316
ogar:kausalDependsContext a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
317+
ogar:kausalConstrainsPath a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
318+
ogar:kausalOnchangePath a rdf:Property ; rdfs:domain ogar:ActionDef ; rdfs:range xsd:string .
314319

315320
# Enumerations
316321
ogar:ActionSubjectKind a owl:Class .

0 commit comments

Comments
 (0)