kibi-core 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema/entities.pl +3 -0
- package/schema/relationships.pl +8 -0
- package/schema/validation.pl +2 -0
- package/src/checks.pl +69 -36
- package/src/discovery.pl +42 -7
- package/src/kb.pl +328 -18
- package/src/requirement_proof.pl +55 -5
- package/src/rule_registry.pl +40 -0
- package/src/semantic_quality.pl +750 -0
- package/src/status.pl +51 -16
- package/src/units.pl +146 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
% Module: semantic_quality
|
|
2
|
+
% Deterministic vocabulary-convergence and redundancy diagnostics.
|
|
3
|
+
%
|
|
4
|
+
% Kibi turns requirement prose into normalized logical terms so Prolog can
|
|
5
|
+
% compare requirements. These checks measure whether that normalization
|
|
6
|
+
% converges:
|
|
7
|
+
%
|
|
8
|
+
% entity-id-style filename stem must equal the frontmatter id
|
|
9
|
+
% domain-redundancy two current requirements ground the same term
|
|
10
|
+
% domain-implication one numeric bound implies another (informational)
|
|
11
|
+
% subject-key-identity subject keys derived from requirement IDs
|
|
12
|
+
% subject-key-shape subject keys outside component.aspect[.sub]
|
|
13
|
+
% ontology-quality predicate schemas whose arguments are mostly
|
|
14
|
+
% one-off atoms (prose smuggled into atoms)
|
|
15
|
+
%
|
|
16
|
+
% Every check is advisory: findings become non-blocking quality diagnostics.
|
|
17
|
+
% Nothing here calls a provider or reads the network; all results are a pure
|
|
18
|
+
% function of the attached KB (plus two optional threshold env variables for
|
|
19
|
+
% ontology-quality).
|
|
20
|
+
|
|
21
|
+
:- module(semantic_quality, [
|
|
22
|
+
logical_ground_signature/2,
|
|
23
|
+
check_entity_id_style/1,
|
|
24
|
+
check_domain_redundancy/1,
|
|
25
|
+
check_domain_redundancy_witnesses/1,
|
|
26
|
+
check_domain_implication/1,
|
|
27
|
+
check_domain_implication_witnesses/1,
|
|
28
|
+
check_subject_key_identity/1,
|
|
29
|
+
check_subject_key_shape/1,
|
|
30
|
+
check_ontology_quality/1,
|
|
31
|
+
ontology_quality_settings/2,
|
|
32
|
+
set_ontology_quality_overrides/2,
|
|
33
|
+
requirement_subject_segment/2,
|
|
34
|
+
valid_subject_key/1,
|
|
35
|
+
subject_vocabulary_json/1,
|
|
36
|
+
subject_claims_json/2
|
|
37
|
+
]).
|
|
38
|
+
|
|
39
|
+
:- use_module(library(http/json)).
|
|
40
|
+
:- use_module(library(pairs)).
|
|
41
|
+
:- use_module(library(apply)).
|
|
42
|
+
:- use_module(library(lists)).
|
|
43
|
+
:- use_module('kb.pl').
|
|
44
|
+
:- use_module('units.pl', [canonical_quantity/6]).
|
|
45
|
+
|
|
46
|
+
% Entity types whose identity is authored in a Markdown file under .kb/.
|
|
47
|
+
markdown_entity_type(req).
|
|
48
|
+
markdown_entity_type(scenario).
|
|
49
|
+
markdown_entity_type(test).
|
|
50
|
+
markdown_entity_type(adr).
|
|
51
|
+
markdown_entity_type(flag).
|
|
52
|
+
markdown_entity_type(event).
|
|
53
|
+
markdown_entity_type(fact).
|
|
54
|
+
|
|
55
|
+
%% ------------------------------------------------------------------
|
|
56
|
+
%% Logical ground signature (shared with logic-coverage)
|
|
57
|
+
%% ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
%% logical_ground_signature(+FactId, -Signature)
|
|
60
|
+
% Canonical, comparable term for one ground fact. Property values are compared
|
|
61
|
+
% after unit canonicalization (30 min and 1800 s share a signature); unknown
|
|
62
|
+
% units stay distinct.
|
|
63
|
+
% implements REQ-kibi-unit-canonicalization, REQ-kibi-domain-redundancy
|
|
64
|
+
logical_ground_signature(FactId, predicate(Namespace, Name, Args, Polarity)) :-
|
|
65
|
+
kb:predicate_fact(FactId, Namespace, Name, Args, Polarity).
|
|
66
|
+
logical_ground_signature(
|
|
67
|
+
FactId,
|
|
68
|
+
property(Subject, Property, Operator, ValueType, Value, Unit, Scope, Polarity)
|
|
69
|
+
) :-
|
|
70
|
+
kb:canonical_property_tuple(
|
|
71
|
+
FactId,
|
|
72
|
+
Subject,
|
|
73
|
+
Property,
|
|
74
|
+
Operator,
|
|
75
|
+
ValueType,
|
|
76
|
+
Value,
|
|
77
|
+
Unit,
|
|
78
|
+
Scope,
|
|
79
|
+
Polarity
|
|
80
|
+
).
|
|
81
|
+
logical_ground_signature(FactId, rule(SemanticKey)) :-
|
|
82
|
+
kb_entity(FactId, fact, Props),
|
|
83
|
+
memberchk(fact_kind=RawKind, Props),
|
|
84
|
+
normalize_term_atom(RawKind, rule),
|
|
85
|
+
( memberchk(semantic_key=RawKey, Props)
|
|
86
|
+
-> normalize_term_atom(RawKey, SemanticKey)
|
|
87
|
+
; memberchk(rule_hash=RawKey, Props), normalize_term_atom(RawKey, SemanticKey)
|
|
88
|
+
).
|
|
89
|
+
|
|
90
|
+
%% ------------------------------------------------------------------
|
|
91
|
+
%% entity-id-style
|
|
92
|
+
%% ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
%% check_entity_id_style(-Violations)
|
|
95
|
+
% Reports Markdown-authored entities whose filename stem differs from their
|
|
96
|
+
% frontmatter id. Purely numeric IDs are reported by the host at creation
|
|
97
|
+
% boundaries (kb_upsert, staged new files) because the compiled KB cannot
|
|
98
|
+
% distinguish a grandfathered legacy entity from a new one.
|
|
99
|
+
% implements REQ-kibi-entity-id-style
|
|
100
|
+
check_entity_id_style(Violations) :-
|
|
101
|
+
findall(Violation, entity_id_style_violation(Violation), Violations0),
|
|
102
|
+
sort(Violations0, Violations).
|
|
103
|
+
|
|
104
|
+
entity_id_style_violation(violation(
|
|
105
|
+
'entity-id-style',
|
|
106
|
+
EntityId,
|
|
107
|
+
Description,
|
|
108
|
+
Suggestion,
|
|
109
|
+
SourceText
|
|
110
|
+
)) :-
|
|
111
|
+
markdown_entity_type(Type),
|
|
112
|
+
kb_entity(EntityId, Type, Props),
|
|
113
|
+
property_text(Props, source, SourceText),
|
|
114
|
+
markdown_source_stem(SourceText, Stem),
|
|
115
|
+
atom_string(EntityId, EntityIdText),
|
|
116
|
+
Stem \== EntityIdText,
|
|
117
|
+
format(string(Description), "Entity ID ~w does not match its filename stem ~w", [EntityId, Stem]),
|
|
118
|
+
format(string(Suggestion), "Rename the file to ~w.md or set frontmatter id: ~w so file names and identities stay aligned", [EntityId, Stem]).
|
|
119
|
+
|
|
120
|
+
markdown_source_stem(SourceText, Stem) :-
|
|
121
|
+
string_concat(Base, ".md", SourceText),
|
|
122
|
+
\+ sub_string(SourceText, _, _, _, "://"),
|
|
123
|
+
split_string(Base, "/\\", "", Parts),
|
|
124
|
+
last(Parts, Stem),
|
|
125
|
+
Stem \== "".
|
|
126
|
+
|
|
127
|
+
%% ------------------------------------------------------------------
|
|
128
|
+
%% domain-redundancy
|
|
129
|
+
%% ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
%% check_domain_redundancy(-Violations)
|
|
132
|
+
% Two distinct current requirements that ground the identical logical term
|
|
133
|
+
% (same signature, therefore same polarity/modality) or link the very same
|
|
134
|
+
% ground fact. Pairs related by supersedes or restates (either direction) are
|
|
135
|
+
% intentional and exempt.
|
|
136
|
+
% implements REQ-kibi-domain-redundancy
|
|
137
|
+
check_domain_redundancy(Violations) :-
|
|
138
|
+
check_domain_redundancy_witnesses(Witnesses),
|
|
139
|
+
maplist(redundancy_witness_violation, Witnesses, Violations).
|
|
140
|
+
|
|
141
|
+
%% check_domain_redundancy_witnesses(-Witnesses)
|
|
142
|
+
% Groups every current requirement ground link once by signature and once by
|
|
143
|
+
% fact ID (keysort + group_pairs_by_key), then pairs requirements only inside
|
|
144
|
+
% a group. Cost is O(L log L) in the number of ground links plus the size of
|
|
145
|
+
% the reported output, never a self-join over all requirement pairs.
|
|
146
|
+
check_domain_redundancy_witnesses(Witnesses) :-
|
|
147
|
+
findall(Key-ground(ReqId, FactId), redundancy_ground_key(ReqId, FactId, Key), Keyed0),
|
|
148
|
+
keysort(Keyed0, Keyed),
|
|
149
|
+
group_pairs_by_key(Keyed, Groups),
|
|
150
|
+
findall(
|
|
151
|
+
dup(ReqA, ReqB, FactA, FactB),
|
|
152
|
+
( member(_Key-Members0, Groups),
|
|
153
|
+
sort(Members0, Members),
|
|
154
|
+
group_duplicate_pair(Members, ReqA, FactA, ReqB, FactB)
|
|
155
|
+
),
|
|
156
|
+
Duplicates0
|
|
157
|
+
),
|
|
158
|
+
sort(Duplicates0, Duplicates),
|
|
159
|
+
maplist(redundancy_witness, Duplicates, Witnesses).
|
|
160
|
+
|
|
161
|
+
redundancy_ground_key(ReqId, FactId, Key) :-
|
|
162
|
+
ground_link(ReqId, FactId),
|
|
163
|
+
kb:current_req(ReqId),
|
|
164
|
+
( logical_ground_signature(FactId, Signature),
|
|
165
|
+
Key = signature(Signature)
|
|
166
|
+
; Key = fact(FactId)
|
|
167
|
+
).
|
|
168
|
+
|
|
169
|
+
ground_link(ReqId, FactId) :-
|
|
170
|
+
member(LinkType, [requires_property, requires_predicate, requires_rule]),
|
|
171
|
+
kb_relationship(LinkType, ReqId, FactId).
|
|
172
|
+
|
|
173
|
+
% Members are sorted by requirement ID, so every emitted pair has
|
|
174
|
+
% ReqA @< ReqB and each unordered pair is produced once per group.
|
|
175
|
+
group_duplicate_pair(Members, ReqA, FactA, ReqB, FactB) :-
|
|
176
|
+
append(_, [ground(ReqA, FactA)|Rest], Members),
|
|
177
|
+
member(ground(ReqB, FactB), Rest),
|
|
178
|
+
ReqA @< ReqB,
|
|
179
|
+
requirements_unrelated(ReqA, ReqB).
|
|
180
|
+
|
|
181
|
+
%% requirements_unrelated(+ReqA, +ReqB)
|
|
182
|
+
% True unless the pair is linked by supersedes or restates in either
|
|
183
|
+
% direction, which marks the overlap as intentional.
|
|
184
|
+
requirements_unrelated(ReqA, ReqB) :-
|
|
185
|
+
\+ requirements_linked(ReqA, ReqB).
|
|
186
|
+
|
|
187
|
+
requirements_linked(ReqA, ReqB) :-
|
|
188
|
+
member(LinkType, [supersedes, restates]),
|
|
189
|
+
( kb_relationship(LinkType, ReqA, ReqB)
|
|
190
|
+
; kb_relationship(LinkType, ReqB, ReqA)
|
|
191
|
+
),
|
|
192
|
+
!.
|
|
193
|
+
|
|
194
|
+
redundancy_witness(dup(ReqA, ReqB, FactA, FactB), Witness) :-
|
|
195
|
+
( FactA == FactB
|
|
196
|
+
-> Match = shared_fact
|
|
197
|
+
; Match = same_signature
|
|
198
|
+
),
|
|
199
|
+
( logical_ground_signature(FactA, Signature)
|
|
200
|
+
-> term_string(Signature, SignatureText, [quoted(true), max_depth(0)])
|
|
201
|
+
; SignatureText = ""
|
|
202
|
+
),
|
|
203
|
+
redundancy_side(ReqA, FactA, Left),
|
|
204
|
+
redundancy_side(ReqB, FactB, Right),
|
|
205
|
+
( Match == shared_fact
|
|
206
|
+
-> format(string(Reason), "Requirements ~w and ~w both link ground fact ~w", [ReqA, ReqB, FactA])
|
|
207
|
+
; format(string(Reason), "Requirements ~w and ~w ground the same logical term through ~w and ~w", [ReqA, ReqB, FactA, FactB])
|
|
208
|
+
),
|
|
209
|
+
Witness = _{
|
|
210
|
+
kind: redundancy,
|
|
211
|
+
status: duplicate,
|
|
212
|
+
match: Match,
|
|
213
|
+
requirements: [ReqA, ReqB],
|
|
214
|
+
facts: [FactA, FactB],
|
|
215
|
+
signature: SignatureText,
|
|
216
|
+
reason: Reason,
|
|
217
|
+
left: Left,
|
|
218
|
+
right: Right
|
|
219
|
+
}.
|
|
220
|
+
|
|
221
|
+
redundancy_side(ReqId, FactId, Side) :-
|
|
222
|
+
entity_text(ReqId, req, source, RequirementSource),
|
|
223
|
+
entity_text(FactId, fact, source, FactSource),
|
|
224
|
+
entity_text(FactId, fact, claim_key, ClaimKey),
|
|
225
|
+
entity_text(FactId, fact, claim_text, ClaimText),
|
|
226
|
+
Side = _{
|
|
227
|
+
requirementId: ReqId,
|
|
228
|
+
requirementSource: RequirementSource,
|
|
229
|
+
factId: FactId,
|
|
230
|
+
factSource: FactSource,
|
|
231
|
+
claimKey: ClaimKey,
|
|
232
|
+
claimText: ClaimText
|
|
233
|
+
}.
|
|
234
|
+
|
|
235
|
+
redundancy_witness_violation(Witness, violation(
|
|
236
|
+
'domain-redundancy',
|
|
237
|
+
EntityId,
|
|
238
|
+
Description,
|
|
239
|
+
"Merge the duplicated requirements, supersede one with the other, or add an explicit restates relationship when the restatement is intentional",
|
|
240
|
+
Source,
|
|
241
|
+
_{witnesses: [Witness]}
|
|
242
|
+
)) :-
|
|
243
|
+
Witness.requirements = [ReqA, ReqB],
|
|
244
|
+
format(string(EntityId), "~w/~w", [ReqA, ReqB]),
|
|
245
|
+
( Witness.signature == ""
|
|
246
|
+
-> Description = Witness.reason
|
|
247
|
+
; format(string(Description), "~w: ~w", [Witness.reason, Witness.signature])
|
|
248
|
+
),
|
|
249
|
+
entity_text(ReqA, req, source, Source).
|
|
250
|
+
|
|
251
|
+
%% ------------------------------------------------------------------
|
|
252
|
+
%% domain-implication (informational)
|
|
253
|
+
%% ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
%% check_domain_implication(-Violations)
|
|
256
|
+
% Same subject and property, comparable numeric operators, one bound strictly
|
|
257
|
+
% implies the other (lte 30 implies lte 60). Reported as "implied by", never as
|
|
258
|
+
% a duplicate: both requirements may be intentional.
|
|
259
|
+
% implements REQ-kibi-domain-redundancy
|
|
260
|
+
check_domain_implication(Violations) :-
|
|
261
|
+
check_domain_implication_witnesses(Witnesses),
|
|
262
|
+
maplist(implication_witness_violation, Witnesses, Violations).
|
|
263
|
+
|
|
264
|
+
check_domain_implication_witnesses(Witnesses) :-
|
|
265
|
+
findall(Key-bound(ReqId, FactId, Operator, Value), implication_bound(ReqId, FactId, Key, Operator, Value), Keyed0),
|
|
266
|
+
keysort(Keyed0, Keyed),
|
|
267
|
+
group_pairs_by_key(Keyed, Groups),
|
|
268
|
+
findall(
|
|
269
|
+
Witness,
|
|
270
|
+
( member(Key-Members0, Groups),
|
|
271
|
+
sort(Members0, Members),
|
|
272
|
+
member(bound(ReqA, FactA, OpA, ValA), Members),
|
|
273
|
+
member(bound(ReqB, FactB, OpB, ValB), Members),
|
|
274
|
+
ReqA \== ReqB,
|
|
275
|
+
bound_implies(OpA, ValA, OpB, ValB),
|
|
276
|
+
requirements_unrelated(ReqA, ReqB),
|
|
277
|
+
implication_witness(Key, ReqA, FactA, OpA, ValA, ReqB, FactB, OpB, ValB, Witness)
|
|
278
|
+
),
|
|
279
|
+
Witnesses0
|
|
280
|
+
),
|
|
281
|
+
sort(Witnesses0, Witnesses).
|
|
282
|
+
|
|
283
|
+
implication_bound(ReqId, FactId, key(Subject, Property, Scope, Unit), Operator, Value) :-
|
|
284
|
+
kb:current_req(ReqId),
|
|
285
|
+
kb_relationship(requires_property, ReqId, FactId),
|
|
286
|
+
kb:canonical_property_tuple(FactId, Subject, Property, Operator, ValueType, Value, Unit, Scope, require),
|
|
287
|
+
memberchk(ValueType, [int, number]),
|
|
288
|
+
memberchk(Operator, [eq, lt, lte, gt, gte]),
|
|
289
|
+
kb_relationship(constrains, ReqId, SubjectFactId),
|
|
290
|
+
kb:fact_subject_key(SubjectFactId, Subject).
|
|
291
|
+
|
|
292
|
+
%% bound_implies(+OpA, +ValA, +OpB, +ValB)
|
|
293
|
+
% Every value allowed by (OpA ValA) is allowed by (OpB ValB), and the two
|
|
294
|
+
% bounds are not identical.
|
|
295
|
+
bound_implies(OpA, ValA, OpB, ValB) :-
|
|
296
|
+
\+ (OpA == OpB, ValA =:= ValB),
|
|
297
|
+
implies_bound(OpA, ValA, OpB, ValB).
|
|
298
|
+
|
|
299
|
+
implies_bound(lte, A, lte, B) :- A < B.
|
|
300
|
+
implies_bound(lte, A, lt, B) :- A < B.
|
|
301
|
+
implies_bound(lt, A, lt, B) :- A < B.
|
|
302
|
+
implies_bound(lt, A, lte, B) :- A =< B.
|
|
303
|
+
implies_bound(gte, A, gte, B) :- A > B.
|
|
304
|
+
implies_bound(gte, A, gt, B) :- A > B.
|
|
305
|
+
implies_bound(gt, A, gt, B) :- A > B.
|
|
306
|
+
implies_bound(gt, A, gte, B) :- A >= B.
|
|
307
|
+
implies_bound(eq, A, lte, B) :- A =< B.
|
|
308
|
+
implies_bound(eq, A, lt, B) :- A < B.
|
|
309
|
+
implies_bound(eq, A, gte, B) :- A >= B.
|
|
310
|
+
implies_bound(eq, A, gt, B) :- A > B.
|
|
311
|
+
|
|
312
|
+
implication_witness(key(Subject, Property, Scope, Unit), ReqA, FactA, OpA, ValA, ReqB, FactB, OpB, ValB, Witness) :-
|
|
313
|
+
redundancy_side(ReqA, FactA, Left),
|
|
314
|
+
redundancy_side(ReqB, FactB, Right),
|
|
315
|
+
format(string(Reason), "~w (~w.~w ~w ~w~w) implies ~w (~w ~w~w)", [ReqA, Subject, Property, OpA, ValA, Unit, ReqB, OpB, ValB, Unit]),
|
|
316
|
+
Witness = _{
|
|
317
|
+
kind: implication,
|
|
318
|
+
status: implied_by,
|
|
319
|
+
requirements: [ReqA, ReqB],
|
|
320
|
+
facts: [FactA, FactB],
|
|
321
|
+
subjectKey: Subject,
|
|
322
|
+
propertyKey: Property,
|
|
323
|
+
scope: Scope,
|
|
324
|
+
unit: Unit,
|
|
325
|
+
reason: Reason,
|
|
326
|
+
stronger: Left,
|
|
327
|
+
weaker: Right
|
|
328
|
+
}.
|
|
329
|
+
|
|
330
|
+
implication_witness_violation(Witness, violation(
|
|
331
|
+
'domain-implication',
|
|
332
|
+
EntityId,
|
|
333
|
+
Description,
|
|
334
|
+
"Informational: keep both bounds if they serve different audiences, or drop the weaker requirement when the stronger one already guarantees it",
|
|
335
|
+
Source,
|
|
336
|
+
_{witnesses: [Witness]}
|
|
337
|
+
)) :-
|
|
338
|
+
Witness.requirements = [ReqA, ReqB],
|
|
339
|
+
format(string(EntityId), "~w/~w", [ReqA, ReqB]),
|
|
340
|
+
format(string(Description), "Implied by: ~w", [Witness.reason]),
|
|
341
|
+
entity_text(ReqB, req, source, Source).
|
|
342
|
+
|
|
343
|
+
%% ------------------------------------------------------------------
|
|
344
|
+
%% subject-key-identity
|
|
345
|
+
%% ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
%% check_subject_key_identity(-Violations)
|
|
348
|
+
% A subject key is req-derived when it has the exact shape
|
|
349
|
+
% `req.<segment>[.<rest>]` and <segment> is the normalized ID of an existing
|
|
350
|
+
% requirement, either whole (`req.req_cli_gc` for REQ-cli-gc) or without its
|
|
351
|
+
% `req_` prefix (`req.opencode_kibi_plugin_v1.document` for
|
|
352
|
+
% REQ-opencode-kibi-plugin-v1). Normalization matches strict modeling:
|
|
353
|
+
% lowercase, and every run of non [a-z0-9] characters becomes `_`. Such keys
|
|
354
|
+
% make every requirement its own subject, so cross-requirement contradiction
|
|
355
|
+
% and redundancy checks can never fire.
|
|
356
|
+
%
|
|
357
|
+
% Subject facts are reported; property_value facts are reported only when no
|
|
358
|
+
% subject fact carries the same key (otherwise fixing the subject fixes them).
|
|
359
|
+
% implements REQ-kibi-subject-vocabulary
|
|
360
|
+
check_subject_key_identity(Violations) :-
|
|
361
|
+
findall(Segment-ReqId, requirement_subject_segment(ReqId, Segment), SegmentPairs0),
|
|
362
|
+
keysort(SegmentPairs0, SegmentPairs),
|
|
363
|
+
group_segment_requirements(SegmentPairs, Segments),
|
|
364
|
+
findall(Key, fact_subject_key_of_kind(_, subject, Key), SubjectKeys0),
|
|
365
|
+
sort(SubjectKeys0, SubjectKeys),
|
|
366
|
+
findall(
|
|
367
|
+
Violation,
|
|
368
|
+
subject_key_identity_violation(Segments, SubjectKeys, Violation),
|
|
369
|
+
Violations0
|
|
370
|
+
),
|
|
371
|
+
sort(Violations0, Violations).
|
|
372
|
+
|
|
373
|
+
subject_key_identity_violation(Segments, SubjectKeys, violation(
|
|
374
|
+
'subject-key-identity',
|
|
375
|
+
FactId,
|
|
376
|
+
Description,
|
|
377
|
+
"Use a shared dotted component.aspect subject key (for example kibi.cli.check.staged) that other requirements about the same component can reuse; update the subject fact and its property_value facts together",
|
|
378
|
+
Source
|
|
379
|
+
)) :-
|
|
380
|
+
member(Kind, [subject, property_value]),
|
|
381
|
+
fact_subject_key_of_kind(FactId, Kind, SubjectKey),
|
|
382
|
+
( Kind == property_value
|
|
383
|
+
-> \+ ord_memberchk(SubjectKey, SubjectKeys)
|
|
384
|
+
; true
|
|
385
|
+
),
|
|
386
|
+
req_derived_subject(SubjectKey, Segments, ReqId),
|
|
387
|
+
format(string(Description), "Subject key ~w is derived from requirement ID ~w, so no other requirement can share this subject", [SubjectKey, ReqId]),
|
|
388
|
+
entity_text(FactId, fact, source, Source).
|
|
389
|
+
|
|
390
|
+
req_derived_subject(SubjectKey, Segments, ReqId) :-
|
|
391
|
+
atomic_list_concat([req, Segment|_], '.', SubjectKey),
|
|
392
|
+
memberchk(Segment-ReqIds, Segments),
|
|
393
|
+
ReqIds = [ReqId|_].
|
|
394
|
+
|
|
395
|
+
%% requirement_subject_segment(?ReqId, -Segment)
|
|
396
|
+
% Normalized requirement ID segments that identify a req-derived subject key.
|
|
397
|
+
requirement_subject_segment(ReqId, Segment) :-
|
|
398
|
+
kb_entity(ReqId, req, _),
|
|
399
|
+
normalized_identifier(ReqId, Normalized),
|
|
400
|
+
( Segment = Normalized
|
|
401
|
+
; atom_concat(req_, Stripped, Normalized),
|
|
402
|
+
Stripped \== '',
|
|
403
|
+
Segment = Stripped
|
|
404
|
+
).
|
|
405
|
+
|
|
406
|
+
normalized_identifier(Raw, Normalized) :-
|
|
407
|
+
normalize_term_atom(Raw, Atom),
|
|
408
|
+
downcase_atom(Atom, Lower),
|
|
409
|
+
atom_codes(Lower, Codes),
|
|
410
|
+
normalize_identifier_codes(Codes, NormalizedCodes0),
|
|
411
|
+
trim_underscores(NormalizedCodes0, NormalizedCodes),
|
|
412
|
+
atom_codes(Normalized, NormalizedCodes).
|
|
413
|
+
|
|
414
|
+
normalize_identifier_codes([], []).
|
|
415
|
+
normalize_identifier_codes([Code|Rest], Normalized) :-
|
|
416
|
+
( identifier_code(Code)
|
|
417
|
+
-> Normalized = [Code|Tail],
|
|
418
|
+
normalize_identifier_codes(Rest, Tail)
|
|
419
|
+
; skip_separator_run(Rest, AfterRun),
|
|
420
|
+
Normalized = [0'_|Tail],
|
|
421
|
+
normalize_identifier_codes(AfterRun, Tail)
|
|
422
|
+
).
|
|
423
|
+
|
|
424
|
+
skip_separator_run([Code|Rest], AfterRun) :-
|
|
425
|
+
\+ identifier_code(Code),
|
|
426
|
+
!,
|
|
427
|
+
skip_separator_run(Rest, AfterRun).
|
|
428
|
+
skip_separator_run(Codes, Codes).
|
|
429
|
+
|
|
430
|
+
identifier_code(Code) :- code_type(Code, digit), !.
|
|
431
|
+
identifier_code(Code) :- Code >= 0'a, Code =< 0'z.
|
|
432
|
+
|
|
433
|
+
trim_underscores(Codes0, Codes) :-
|
|
434
|
+
exclude_leading_underscores(Codes0, Codes1),
|
|
435
|
+
reverse(Codes1, Reversed0),
|
|
436
|
+
exclude_leading_underscores(Reversed0, Reversed),
|
|
437
|
+
reverse(Reversed, Codes).
|
|
438
|
+
|
|
439
|
+
exclude_leading_underscores([0'_|Rest], Codes) :- !, exclude_leading_underscores(Rest, Codes).
|
|
440
|
+
exclude_leading_underscores(Codes, Codes).
|
|
441
|
+
|
|
442
|
+
% Group Segment-ReqId pairs (already keysorted) into Segment-[ReqIds].
|
|
443
|
+
group_segment_requirements(SegmentPairs, Grouped) :-
|
|
444
|
+
group_pairs_by_key(SegmentPairs, Grouped0),
|
|
445
|
+
maplist(sort_group_values, Grouped0, Grouped).
|
|
446
|
+
|
|
447
|
+
sort_group_values(Key-Values0, Key-Values) :-
|
|
448
|
+
sort(Values0, Values).
|
|
449
|
+
|
|
450
|
+
%% ------------------------------------------------------------------
|
|
451
|
+
%% subject-key-shape
|
|
452
|
+
%% ------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
%% check_subject_key_shape(-Violations)
|
|
455
|
+
% Subject keys follow one convention: dotted `component.aspect[.sub]` with at
|
|
456
|
+
% least two segments, each a lowercase snake identifier ([a-z][a-z0-9]* joined
|
|
457
|
+
% by single underscores).
|
|
458
|
+
% implements REQ-kibi-subject-vocabulary
|
|
459
|
+
check_subject_key_shape(Violations) :-
|
|
460
|
+
findall(Violation, subject_key_shape_violation(Violation), Violations0),
|
|
461
|
+
sort(Violations0, Violations).
|
|
462
|
+
|
|
463
|
+
subject_key_shape_violation(violation(
|
|
464
|
+
'subject-key-shape',
|
|
465
|
+
FactId,
|
|
466
|
+
Description,
|
|
467
|
+
"Rename the subject key to dotted component.aspect[.sub] with lowercase snake segments (for example kibi.cli.check.staged or opencode.kibi_plugin.guidance)",
|
|
468
|
+
Source
|
|
469
|
+
)) :-
|
|
470
|
+
fact_subject_key_of_kind(FactId, subject, SubjectKey),
|
|
471
|
+
\+ valid_subject_key(SubjectKey),
|
|
472
|
+
format(string(Description), "Subject key ~w does not follow the component.aspect[.sub] convention", [SubjectKey]),
|
|
473
|
+
entity_text(FactId, fact, source, Source).
|
|
474
|
+
|
|
475
|
+
%% valid_subject_key(+SubjectKey)
|
|
476
|
+
valid_subject_key(SubjectKey) :-
|
|
477
|
+
atom(SubjectKey),
|
|
478
|
+
atomic_list_concat(Segments, '.', SubjectKey),
|
|
479
|
+
Segments = [_, _|_],
|
|
480
|
+
maplist(valid_subject_segment, Segments).
|
|
481
|
+
|
|
482
|
+
valid_subject_segment(Segment) :-
|
|
483
|
+
atomic_list_concat(Words, '_', Segment),
|
|
484
|
+
Words = [First|_],
|
|
485
|
+
atom_codes(First, [Initial|_]),
|
|
486
|
+
Initial >= 0'a, Initial =< 0'z,
|
|
487
|
+
maplist(valid_subject_word, Words).
|
|
488
|
+
|
|
489
|
+
valid_subject_word(Word) :-
|
|
490
|
+
atom_codes(Word, Codes),
|
|
491
|
+
Codes \== [],
|
|
492
|
+
maplist(identifier_code, Codes).
|
|
493
|
+
|
|
494
|
+
%% ------------------------------------------------------------------
|
|
495
|
+
%% ontology-quality (informational)
|
|
496
|
+
%% ------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
%% check_ontology_quality(-Violations)
|
|
499
|
+
% Reports predicate schemas whose ground facts mostly use argument values that
|
|
500
|
+
% occur in exactly one fact. A high singleton ratio means compressed prose is
|
|
501
|
+
% being written into atoms, so paraphrases produce different terms and no
|
|
502
|
+
% cross-requirement comparison can fire. Schemas with fewer facts than the
|
|
503
|
+
% configured minimum are skipped so small vocabularies are not noise.
|
|
504
|
+
% implements REQ-kibi-ontology-quality
|
|
505
|
+
check_ontology_quality(Violations) :-
|
|
506
|
+
ontology_quality_settings(Threshold, MinFacts),
|
|
507
|
+
findall(key(Namespace, Name, Arity)-Args, ontology_predicate_args(Namespace, Name, Arity, Args), Keyed0),
|
|
508
|
+
keysort(Keyed0, Keyed),
|
|
509
|
+
group_pairs_by_key(Keyed, Groups),
|
|
510
|
+
findall(
|
|
511
|
+
Violation,
|
|
512
|
+
( member(Key-ArgLists, Groups),
|
|
513
|
+
ontology_quality_violation(Key, ArgLists, Threshold, MinFacts, Violation)
|
|
514
|
+
),
|
|
515
|
+
Violations0
|
|
516
|
+
),
|
|
517
|
+
sort(Violations0, Violations).
|
|
518
|
+
|
|
519
|
+
%% ontology_quality_settings(-MaxSingletonRatio, -MinFacts)
|
|
520
|
+
% Defaults: ratio 0.6, minimum 8 facts. KIBI_ONTOLOGY_QUALITY_MAX_SINGLETON_RATIO
|
|
521
|
+
% (0..1) and KIBI_ONTOLOGY_QUALITY_MIN_FACTS (positive integer) override them.
|
|
522
|
+
% The host forwards its own environment per check call through
|
|
523
|
+
% set_ontology_quality_overrides/2 because the engine daemon outlives the
|
|
524
|
+
% invoking process; otherwise the engine's environment applies. Invalid values
|
|
525
|
+
% fall back to the defaults.
|
|
526
|
+
ontology_quality_settings(Threshold, MinFacts) :-
|
|
527
|
+
setting_value(ratio, 'KIBI_ONTOLOGY_QUALITY_MAX_SINGLETON_RATIO', valid_ratio, 0.6, Threshold),
|
|
528
|
+
setting_value(min_facts, 'KIBI_ONTOLOGY_QUALITY_MIN_FACTS', valid_min_facts, 8, MinFacts).
|
|
529
|
+
|
|
530
|
+
:- dynamic ontology_quality_override/2.
|
|
531
|
+
|
|
532
|
+
%% set_ontology_quality_overrides(+Ratio, +MinFacts)
|
|
533
|
+
% Replace the per-call overrides. Pass the atom default to clear one.
|
|
534
|
+
set_ontology_quality_overrides(Ratio, MinFacts) :-
|
|
535
|
+
retractall(ontology_quality_override(_, _)),
|
|
536
|
+
(Ratio == default -> true ; assertz(ontology_quality_override(ratio, Ratio))),
|
|
537
|
+
(MinFacts == default -> true ; assertz(ontology_quality_override(min_facts, MinFacts))).
|
|
538
|
+
|
|
539
|
+
setting_value(Key, EnvName, Validator, Default, Value) :-
|
|
540
|
+
( ontology_quality_override(Key, Override),
|
|
541
|
+
call(Validator, Override)
|
|
542
|
+
-> Value = Override
|
|
543
|
+
; getenv(EnvName, Raw),
|
|
544
|
+
catch(atom_number(Raw, Parsed), _, fail),
|
|
545
|
+
call(Validator, Parsed)
|
|
546
|
+
-> Value = Parsed
|
|
547
|
+
; Value = Default
|
|
548
|
+
).
|
|
549
|
+
|
|
550
|
+
valid_ratio(Value) :- number(Value), Value >= 0, Value =< 1.
|
|
551
|
+
valid_min_facts(Value) :- integer(Value), Value >= 1.
|
|
552
|
+
|
|
553
|
+
ontology_predicate_args(Namespace, Name, Arity, Args) :-
|
|
554
|
+
kb:predicate_fact(_FactId, Namespace, Name, Args, _Polarity),
|
|
555
|
+
length(Args, Arity),
|
|
556
|
+
Arity > 0.
|
|
557
|
+
|
|
558
|
+
ontology_quality_violation(key(Namespace, Name, Arity), ArgLists, Threshold, MinFacts, violation(
|
|
559
|
+
'ontology-quality',
|
|
560
|
+
EntityId,
|
|
561
|
+
Description,
|
|
562
|
+
Suggestion,
|
|
563
|
+
Source,
|
|
564
|
+
_{witnesses: [Witness]}
|
|
565
|
+
)) :-
|
|
566
|
+
length(ArgLists, FactCount),
|
|
567
|
+
FactCount >= MinFacts,
|
|
568
|
+
numlist(1, Arity, Positions),
|
|
569
|
+
maplist(position_singletons(ArgLists), Positions, PositionStats),
|
|
570
|
+
foldl(sum_singletons, PositionStats, 0, Singletons),
|
|
571
|
+
Slots is FactCount * Arity,
|
|
572
|
+
Ratio is Singletons / Slots,
|
|
573
|
+
Ratio >= Threshold,
|
|
574
|
+
findall(SchemaId, kb:predicate_schema(SchemaId, Namespace, Name, Arity, _, _), SchemaIds0),
|
|
575
|
+
sort(SchemaIds0, SchemaIds),
|
|
576
|
+
( SchemaIds = [EntityId|_]
|
|
577
|
+
-> entity_text(EntityId, fact, source, Source)
|
|
578
|
+
; format(string(EntityId), "~w:~w/~w", [Namespace, Name, Arity]),
|
|
579
|
+
Source = ""
|
|
580
|
+
),
|
|
581
|
+
schema_argument_names(SchemaIds, Arity, ArgumentNames),
|
|
582
|
+
maplist(position_evidence(ArgumentNames), PositionStats, PositionEvidence),
|
|
583
|
+
RatioPercent is round(Ratio * 100),
|
|
584
|
+
prose_arguments(ArgumentNames, PositionStats, FactCount, Threshold, ProseText),
|
|
585
|
+
format(string(Description), "Predicate ~w/~w: ~w% of argument values across ~w facts occur in only one fact; prose-like arguments: ~w, so paraphrases will not converge", [Name, Arity, RatioPercent, FactCount, ProseText]),
|
|
586
|
+
format(string(Suggestion), "Give the prose-like arguments of ~w a small shared vocabulary: declare argument_constants on its predicate_schema, map existing spellings with argument_aliases, and let kibi migrate rewrite the facts; split the predicate when an argument has no natural vocabulary", [Name]),
|
|
587
|
+
Witness = _{
|
|
588
|
+
kind: ontology_quality,
|
|
589
|
+
predicateNamespace: Namespace,
|
|
590
|
+
predicateName: Name,
|
|
591
|
+
arity: Arity,
|
|
592
|
+
schemaIds: SchemaIds,
|
|
593
|
+
factCount: FactCount,
|
|
594
|
+
singletonRatio: Ratio,
|
|
595
|
+
threshold: Threshold,
|
|
596
|
+
minFacts: MinFacts,
|
|
597
|
+
positions: PositionEvidence
|
|
598
|
+
}.
|
|
599
|
+
|
|
600
|
+
position_singletons(ArgLists, Position, stats(Position, Distinct, Singletons)) :-
|
|
601
|
+
findall(Value, (member(Args, ArgLists), nth1(Position, Args, Value)), Values0),
|
|
602
|
+
msort(Values0, Values),
|
|
603
|
+
clumped(Values, Counts),
|
|
604
|
+
length(Counts, Distinct),
|
|
605
|
+
include(singleton_count, Counts, SingletonCounts),
|
|
606
|
+
length(SingletonCounts, Singletons).
|
|
607
|
+
|
|
608
|
+
singleton_count(_-1).
|
|
609
|
+
|
|
610
|
+
% Arguments whose own singleton share reaches the threshold, as
|
|
611
|
+
% "name (singletons/facts)"; the whole predicate is listed when no single
|
|
612
|
+
% argument crosses it on its own.
|
|
613
|
+
prose_arguments(ArgumentNames, PositionStats, FactCount, Threshold, Text) :-
|
|
614
|
+
findall(
|
|
615
|
+
Label,
|
|
616
|
+
( member(stats(Position, _Distinct, Singletons), PositionStats),
|
|
617
|
+
Singletons / FactCount >= Threshold,
|
|
618
|
+
nth1(Position, ArgumentNames, Argument),
|
|
619
|
+
format(string(Label), "~w (~w/~w)", [Argument, Singletons, FactCount])
|
|
620
|
+
),
|
|
621
|
+
Labels
|
|
622
|
+
),
|
|
623
|
+
( Labels == []
|
|
624
|
+
-> Text = "spread across all arguments"
|
|
625
|
+
; atomic_list_concat(Labels, ', ', Text)
|
|
626
|
+
).
|
|
627
|
+
|
|
628
|
+
sum_singletons(stats(_, _, Singletons), Acc0, Acc) :-
|
|
629
|
+
Acc is Acc0 + Singletons.
|
|
630
|
+
|
|
631
|
+
schema_argument_names([SchemaId|_], Arity, Names) :-
|
|
632
|
+
kb:predicate_schema(SchemaId, _, _, Arity, Names, _),
|
|
633
|
+
!.
|
|
634
|
+
schema_argument_names(_, Arity, Names) :-
|
|
635
|
+
numlist(1, Arity, Positions),
|
|
636
|
+
maplist([Position, Name]>>format(atom(Name), "arg~w", [Position]), Positions, Names).
|
|
637
|
+
|
|
638
|
+
position_evidence(ArgumentNames, stats(Position, Distinct, Singletons), _{
|
|
639
|
+
argument: Argument,
|
|
640
|
+
distinctValues: Distinct,
|
|
641
|
+
singletonValues: Singletons
|
|
642
|
+
}) :-
|
|
643
|
+
nth1(Position, ArgumentNames, Argument).
|
|
644
|
+
|
|
645
|
+
%% ------------------------------------------------------------------
|
|
646
|
+
%% Vocabulary reads for modeling-time subject ranking
|
|
647
|
+
%% ------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
%% subject_vocabulary_json(-Json)
|
|
650
|
+
% Existing subject facts with the titles of the current requirements that
|
|
651
|
+
% constrain them. The host ranks these deterministically (and optionally via a
|
|
652
|
+
% vocabulary-alignment plugin) so new clauses reuse an existing subject.
|
|
653
|
+
% implements REQ-kibi-subject-vocabulary
|
|
654
|
+
subject_vocabulary_json(Json) :-
|
|
655
|
+
findall(Segment-ReqId, requirement_subject_segment(ReqId, Segment), SegmentPairs0),
|
|
656
|
+
keysort(SegmentPairs0, SegmentPairs),
|
|
657
|
+
group_segment_requirements(SegmentPairs, Segments),
|
|
658
|
+
findall(Entry, subject_vocabulary_entry(Segments, Entry), Entries0),
|
|
659
|
+
sort(subjectKey, @=<, Entries0, Entries),
|
|
660
|
+
with_output_to(string(Json), json_write_dict(current_output, Entries, [width(0)])).
|
|
661
|
+
|
|
662
|
+
% reqDerived marks keys that subject-key-identity rejects, so the host never
|
|
663
|
+
% proposes them for reuse.
|
|
664
|
+
subject_vocabulary_entry(Segments, _{
|
|
665
|
+
factId: FactId,
|
|
666
|
+
subjectKey: SubjectKey,
|
|
667
|
+
title: Title,
|
|
668
|
+
reqDerived: ReqDerived,
|
|
669
|
+
requirements: Requirements
|
|
670
|
+
}) :-
|
|
671
|
+
fact_subject_key_of_kind(FactId, subject, SubjectKey),
|
|
672
|
+
( req_derived_subject(SubjectKey, Segments, _)
|
|
673
|
+
-> ReqDerived = true
|
|
674
|
+
; ReqDerived = false
|
|
675
|
+
),
|
|
676
|
+
entity_text(FactId, fact, title, Title),
|
|
677
|
+
findall(
|
|
678
|
+
_{id: ReqId, title: ReqTitle},
|
|
679
|
+
( kb_relationship(constrains, ReqId, FactId),
|
|
680
|
+
kb:current_req(ReqId),
|
|
681
|
+
entity_text(ReqId, req, title, ReqTitle)
|
|
682
|
+
),
|
|
683
|
+
Requirements0
|
|
684
|
+
),
|
|
685
|
+
sort(id, @<, Requirements0, Requirements).
|
|
686
|
+
|
|
687
|
+
%% subject_claims_json(+SubjectKeys, -Json)
|
|
688
|
+
% Existing property_value claims on the given subjects, with their canonical
|
|
689
|
+
% signature text and the current requirements that ground them. Used by the
|
|
690
|
+
% host to surface possible paraphrase duplicates (different signatures, same
|
|
691
|
+
% subject) for review; it never decides equivalence.
|
|
692
|
+
% implements REQ-kibi-subject-vocabulary
|
|
693
|
+
subject_claims_json(SubjectKeys0, Json) :-
|
|
694
|
+
maplist(normalize_term_atom, SubjectKeys0, SubjectKeys1),
|
|
695
|
+
sort(SubjectKeys1, SubjectKeys),
|
|
696
|
+
findall(Claim, subject_claim_entry(SubjectKeys, Claim), Claims0),
|
|
697
|
+
sort(factId, @<, Claims0, Claims),
|
|
698
|
+
with_output_to(string(Json), json_write_dict(current_output, Claims, [width(0)])).
|
|
699
|
+
|
|
700
|
+
subject_claim_entry(SubjectKeys, _{
|
|
701
|
+
factId: FactId,
|
|
702
|
+
subjectKey: SubjectKey,
|
|
703
|
+
propertyKey: PropertyKey,
|
|
704
|
+
signature: SignatureText,
|
|
705
|
+
claimText: ClaimText,
|
|
706
|
+
title: Title,
|
|
707
|
+
requirements: Requirements
|
|
708
|
+
}) :-
|
|
709
|
+
member(SubjectKey, SubjectKeys),
|
|
710
|
+
fact_subject_key_of_kind(FactId, property_value, SubjectKey),
|
|
711
|
+
kb:fact_property_tuple(FactId, SubjectKey, PropertyKey, _, _, _, _, _, _),
|
|
712
|
+
logical_ground_signature(FactId, Signature),
|
|
713
|
+
term_string(Signature, SignatureText, [quoted(true), max_depth(0)]),
|
|
714
|
+
entity_text(FactId, fact, claim_text, ClaimText),
|
|
715
|
+
entity_text(FactId, fact, title, Title),
|
|
716
|
+
findall(ReqId, (kb_relationship(requires_property, ReqId, FactId), kb:current_req(ReqId)), Requirements0),
|
|
717
|
+
sort(Requirements0, Requirements).
|
|
718
|
+
|
|
719
|
+
%% ------------------------------------------------------------------
|
|
720
|
+
%% Shared helpers
|
|
721
|
+
%% ------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
fact_subject_key_of_kind(FactId, Kind, SubjectKey) :-
|
|
724
|
+
kb_entity(FactId, fact, Props),
|
|
725
|
+
memberchk(fact_kind=RawKind, Props),
|
|
726
|
+
normalize_term_atom(RawKind, Kind),
|
|
727
|
+
memberchk(subject_key=RawKey, Props),
|
|
728
|
+
normalize_term_atom(RawKey, SubjectKey).
|
|
729
|
+
|
|
730
|
+
entity_text(EntityId, Type, Key, Text) :-
|
|
731
|
+
( kb_entity(EntityId, Type, Props)
|
|
732
|
+
-> property_text(Props, Key, Text)
|
|
733
|
+
; Text = ""
|
|
734
|
+
).
|
|
735
|
+
|
|
736
|
+
%% property_text(+Props, +Key, -Text)
|
|
737
|
+
% Raw property value as a string. Unlike normalize_term_atom/2 this keeps
|
|
738
|
+
% path separators, so source paths are returned whole.
|
|
739
|
+
property_text(Props, Key, Text) :-
|
|
740
|
+
( memberchk(Key=Raw, Props)
|
|
741
|
+
-> raw_text(Raw, Text)
|
|
742
|
+
; Text = ""
|
|
743
|
+
).
|
|
744
|
+
|
|
745
|
+
raw_text(^^(Value, _Type), Text) :- !, raw_text(Value, Text).
|
|
746
|
+
raw_text(literal(type(_, Value)), Text) :- !, raw_text(Value, Text).
|
|
747
|
+
raw_text(literal(Value), Text) :- !, raw_text(Value, Text).
|
|
748
|
+
raw_text(Value, Text) :- string(Value), !, Text = Value.
|
|
749
|
+
raw_text(Value, Text) :- atom(Value), !, atom_string(Value, Text).
|
|
750
|
+
raw_text(Value, Text) :- term_string(Value, Text).
|