kibi-core 0.12.0 → 0.14.0

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.
@@ -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).