Skip to content

Commit a3a9dd8

Browse files
committed
Merge branch 'fix/336-341-plan-defects' into land/oo-2026-10-28
# Conflicts: # CHANGELOG.md
2 parents a07dca3 + 7234ac7 commit a3a9dd8

14 files changed

Lines changed: 1307 additions & 81 deletions

‎CHANGELOG.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,38 @@ All notable changes to Open Ontologies are documented here.
66

77
### Added
88

9+
- **A plan says whether the change breaks the ontology, and scores it `high`
10+
when it does** (#336, reported by @jpsanchezcalypso). `risk_score` came from
11+
removal counts alone, so a change that made a named class unsatisfiable (R1:
12+
`Manager ⊑ Person ⊑ Agent`, `Manager ⊥ Agent`) or the ABox inconsistent (R2:
13+
`Machine ⊥ Person`, `alice a Machine` where `alice a Person`) removed nothing
14+
and scored `low`. `plan`, `batch` `plan` and `onto_plan` now carry a `logic`
15+
block through one function (`Planner::plan_with`): `new_defects`, the
16+
`defects` findings the store did not have, and `abox_consistent`, the owl-dl
17+
tableaux on the proposed graph under the `[reasoner]` budget, with
18+
`abox_consistent_before` for the store when the proposed graph is
19+
inconsistent. A new error- or warning-severity defect, or an inconsistency
20+
the store was not proven to have, sets `risk_score` to `high`, and the new
21+
`risk_reasons` says why. A check that ran out of budget, or a graph using a
22+
construct the tableaux does not model, reports `abox_consistent: null` with
23+
the reason, never `true`. On by default; `--no-logic-check` (CLI, `batch`) or
24+
`check_logic: false` (`onto_plan`) skips it and the block says
25+
`not_checked`. `apply` is unchanged and does not gate on the verdict, and the
26+
exit code of `plan` is unchanged (1 on an error only), as it already was for
27+
a `high` score from removals; `docs/lifecycle.md` says so. R1 and R2 now plan
28+
`high` on all four entry points; the benign control stays `low`.
29+
- **`defects` says how many classes are affected, and its 50-a-kind listing
30+
can be lifted** (#341). `defect_count` counts findings, one per query row,
31+
and for `inherited_disjoint` a row is a (class, disjoint pair), so a class
32+
under two disjoint pairs counted twice and the headline ran higher than the
33+
reasoner's list of unsatisfiable classes with nothing saying why. The report
34+
now carries `affected_classes` and `affected_properties` (distinct subjects)
35+
beside it, and `kind_counts` gives `findings` and the affected count for each
36+
of the eight kinds. `defects --limit N` on the CLI and `limit` on
37+
`onto_defects` set the per-kind listing bound (default 50, `0` lists every
38+
row), and `truncated` names a kind only when the bound in force cut it. On
39+
the issue's reproduction: `defect_count` 2, `affected_classes` 1; with 80
40+
classes under one disjoint pair, `--limit 0` lists all 80.
941
- **`onto_convert`, the CLI `convert` and `batch` `convert` write OWL 2
1042
functional syntax (`--to ofn`)** (requested by @noahgift in #322). #311 gave
1143
the engine an OFN reader and no writer, so handing an ontology to the OWL
@@ -141,6 +173,17 @@ All notable changes to Open Ontologies are documented here.
141173
sites and cannot error. W3C `core/property/or-datatypes-001` reports 3 of 3
142174
violations where it reported 1: full-tuple column 51 to 52 PASS, report
143175
match 48 to 49 of 56, gated column unchanged at 61.
176+
177+
- **`batch` runs `defects`** (#336). It had no arm, so `batch` answered
178+
`{"error":"unknown batch command: 'defects'"}`, and so did the CLI `defects`
179+
whenever a daemon was up, since the CLI proxies it there. `defects [path]
180+
[--limit N]` checks the file alone, or the batch's own store when no path is
181+
given. The proxy coverage test now lists `defects`. The local CLI `defects`
182+
checked its file inside the main store, so under persistent storage it
183+
judged the file together with whatever the store held and left the file's
184+
triples behind in it; it now uses a store of its own, as the proxied form
185+
does. No other MCP-only check (`onto_dlp_boundary`, `onto_dl_check`) is a CLI
186+
command, so none is proxied and none was given a batch arm.
144187
- **`diff` and `drift` no longer hang on a symmetric blank-node graph** (#293).
145188
Both canonicalised blank nodes through oxrdf's RDFC-1.0, which sets no limit
146189
on `hash_n_degree_quads` calls, so the cost was factorial: a 7-node

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ the part that survives examination.
118118
> **This is not an ontology editor.** Use Protégé to draw class hierarchies. You
119119
> run Open Ontologies on the change, before the change goes to production.
120120
>
121-
> **The tool is like Terraform, and this is on purpose. But the plan is semantic,
122-
> not syntactic.** A text diff is `git diff`, and you have `git diff` already.
121+
> **The tool is like Terraform, and this is on purpose. But the plan checks what a
122+
> change means and what it breaks, not only its text.** A text diff is `git diff`,
123+
> and you have `git diff` already.
123124
124125
You do not need a JVM. You do not need Protégé. The engine speaks MCP to Claude,
125126
to Cursor, and to other clients of that protocol.

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ new consequences 901 rule table owl-rl, in 0.04s
110110
> **这不是一个本体编辑器。**要绘制类层次结构,请使用 Protégé。你在变更进入生产环境之前,
111111
> 对这次变更运行 Open Ontologies。
112112
>
113-
> **本工具在风格上类似 Terraform,这是刻意为之。但是这里的计划是语义层面的,不是语法层面的。**
113+
> **本工具在风格上类似 Terraform,这是刻意为之。但是这里的计划检查变更的含义以及它会破坏什么,而不只是检查文本。**
114114
> 文本差异就是 `git diff`,而你已经有 `git diff` 了。
115115
116116
你不需要 JVM。你不需要 Protégé。引擎通过 MCP 与 Claude、Cursor 以及该协议的其他客户端通信。

‎docs/lifecycle.md‎

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,55 @@ therefore survive the process that computed them: `plan` and `apply` are
3333
separate CLI invocations and separate MCP calls, and neither shares memory with
3434
the other. The 100 most recent plans are retained.
3535

36-
### Conservativity: the one part of a plan that is about meaning
36+
### Conservativity: what the change means
3737

3838
Everything above is about SHAPE. A change that adds one `rdfs:domain` triple adds
3939
no class, removes nothing, has a blast radius of zero and scores `low`, while
40-
retyping every existing individual of that property. Pass
41-
`check_conservativity: true` and the plan also reports whether the change alters
42-
any consequence over the names the store already uses, with the rule and the
43-
premises the base lacked behind every row.
44-
45-
It is opt-in because it reasons both graphs to a fixpoint, and when it is off the
46-
`conservativity` block is still there and reads `ran: false` with the reason: a
47-
missing block and a clean block look the same to a dashboard. It is never fatal;
48-
a check that cannot run leaves `conservativity.skipped` set and the plan is still
49-
returned. The verdict is a WORD rather than a boolean, and it is conservativity
50-
with respect to a Horn rule table rather than in a description logic, which
40+
retyping every existing individual of that property. The `conservativity` block
41+
reports whether the change alters any consequence over the names the store
42+
already uses, with the rule and the premises the base lacked behind every row.
43+
44+
It is on by default since #196 and costs a fixpoint over both graphs; skip it with
45+
`--no-conservativity` (CLI and `batch`) or `check_conservativity: false`
46+
(`onto_plan`). When it is off the `conservativity` block is still there and reads
47+
`ran: false` with the reason: a missing block and a clean block look the same to a
48+
dashboard. It is never fatal; a check that cannot run leaves
49+
`conservativity.skipped` set and the plan is still returned. The verdict is a WORD
50+
rather than a boolean, and it is conservativity with respect to a Horn rule table
51+
rather than in a description logic, which
5152
[docs/modules-and-conservativity.md](modules-and-conservativity.md) states in
5253
full.
5354

55+
### Logic: what the change breaks
56+
57+
Conservativity does not see a change that breaks the ontology. Adding
58+
`Manager rdfs:subClassOf Person` and `Manager owl:disjointWith Agent`, where
59+
every `Person` is an `Agent`, leaves `Manager` with no possible instance; adding
60+
`Machine owl:disjointWith Person` and `alice a Machine`, where `alice` is a
61+
`Person`, makes the ABox inconsistent. Both remove nothing and both used to score
62+
`low` (#336). The `logic` block answers this, relative to the store, so a defect
63+
the store already had is never charged to the change:
64+
65+
| field | meaning |
66+
|---|---|
67+
| `new_defects` | findings of the eight `defects` kinds in the proposed graph that the store did not have; `null` if the check could not run |
68+
| `new_defects_by_severity`, `new_defect_findings` | the same, by severity, and listed (at most 50) |
69+
| `abox_consistent` | the owl-dl tableaux on the proposed graph: `false` on a clash it found, `true` only when the search finished and the graph uses no construct the tableaux does not model, otherwise `null` |
70+
| `abox_consistent_before` | the same question of the store, asked only when the proposed graph is inconsistent; `null` otherwise |
71+
| `checked_by`, `reason` | what ran, and in words why each answer is what it is; `not_checked` when skipped |
72+
73+
A new `error` or `warning` defect, or an inconsistency the store was not proven to
74+
have, sets `risk_score` to `high`, and `risk_reasons` says which. An `info`
75+
finding is listed and does not move the score. The consistency half runs under
76+
the `[reasoner]` budget (`tableaux_test_timeout_ms` per phase, under
77+
`classify_timeout_ms`), and a run that exhausts it reports `null` with the reason,
78+
never `true`. Skip the whole block with `--no-logic-check` or
79+
`check_logic: false`.
80+
81+
The exit code of `plan` does not change with the verdict: it is 1 on an error and
82+
0 otherwise, as it always was for a `high` score from removals. A pipeline gates
83+
on `risk_score` or `logic`.
84+
5485
## Enforce
5586

5687
Design pattern checks. Built-in packs: `generic` (orphan classes, missing labels), `boro` (IES4/BORO compliance), `value_partition` (disjointness). Custom SPARQL rules supported.
@@ -60,6 +91,12 @@ Design pattern checks. Built-in packs: `generic` (orphan classes, missing labels
6091
Two modes: `safe` (write the delta) or `migrate` (same, plus
6192
owl:equivalentClass/Property bridges for consumers).
6293

94+
Apply does not re-judge the plan. It applies a plan whatever its `risk_score` or
95+
`logic` block said, and the only gate is the monitor (`force` skips that too). A
96+
change that removes terms on purpose has always been applicable at `high`, and an
97+
apply that began refusing on a field it never read would break the pipelines
98+
built on it without a word.
99+
63100
Apply writes only the triples that differ, so an unchanged graph costs nothing
64101
and a one-instance change touches one triple. `strategy` reports `delta`, or
65102
`reload` when blank nodes are present anywhere in either graph: bnode labels are

‎src/batch.rs‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ impl BatchRunner {
126126
"query" => self.exec_query(&cmd.args),
127127
"validate" => self.exec_validate(&cmd.args),
128128
"lint" => self.exec_lint(&cmd.args),
129+
"defects" => self.exec_defects(&cmd.args),
129130
"reason" => self.exec_reason(&cmd.args),
130131
"certificate-check" | "certificate_check" => self.exec_certificate_check(&cmd.args),
131132
"fol" => self.exec_fol(&cmd.args),
@@ -281,6 +282,51 @@ impl BatchRunner {
281282
}
282283
}
283284

285+
/// `defects [path] [--limit N]`. With a path, the file is checked alone,
286+
/// as the CLI subcommand checks it; without one, the batch's own store is,
287+
/// so `load x.ttl` then `defects` checks what was loaded. The CLI proxies
288+
/// `defects` here when a daemon is up, and there was no arm, so it answered
289+
/// `unknown batch command` and exited 1 (#336).
290+
fn exec_defects(&self, args: &[String]) -> Value {
291+
use crate::defects::Defects;
292+
let limit = match Self::flag_value(args, "--limit") {
293+
None => Defects::MAX_PER_KIND,
294+
Some(v) => match v.parse::<usize>() {
295+
Ok(n) => n,
296+
Err(_) => return json!({"error": format!("--limit takes a whole number, got '{v}'")}),
297+
},
298+
};
299+
let mut skip_next = false;
300+
let path = args.iter().find(|a| {
301+
if std::mem::take(&mut skip_next) {
302+
return false;
303+
}
304+
if a.as_str() == "--limit" {
305+
skip_next = true;
306+
return false;
307+
}
308+
!a.starts_with("--")
309+
});
310+
let graph = match path {
311+
None => self.graph.clone(),
312+
Some(p) => {
313+
let content = match crate::graph::GraphStore::read_as_turtle(p) {
314+
Ok(c) => c,
315+
Err(e) => return json!({"error": e.to_string()}),
316+
};
317+
let g = Arc::new(GraphStore::new());
318+
if let Err(e) = g.load_turtle(&content, None) {
319+
return json!({"error": e.to_string()});
320+
}
321+
g
322+
}
323+
};
324+
match Defects::check_with_limit(&graph, limit) {
325+
Ok(result) => serde_json::from_str(&result).unwrap_or(json!({"raw": result})),
326+
Err(e) => json!({"error": e.to_string()}),
327+
}
328+
}
329+
284330
fn exec_reason(&self, args: &[String]) -> Value {
285331
use crate::reason::{InferenceTarget, Reasoner};
286332
let profile = Self::flag_value(args, "--profile")
@@ -960,7 +1006,12 @@ impl BatchRunner {
9601006
max_rows: 1_000_000,
9611007
}
9621008
});
963-
let result = planner.plan_checked(&turtle, opts)
1009+
// The logic check (#336) is on by default for the same reason.
1010+
let checks = crate::plan::PlanChecks {
1011+
conservativity: opts,
1012+
logic: !args.iter().any(|a| a == "--no-logic-check"),
1013+
};
1014+
let result = planner.plan_with(&turtle, checks)
9641015
.unwrap_or_else(|e| format!(r#"{{"error":"{}"}}"#, e));
9651016
serde_json::from_str(&result).unwrap_or(json!({"raw": result}))
9661017
}

0 commit comments

Comments
 (0)