kibi-core 0.2.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Core Prolog modules and RDF graph logic for Kibi",
6
6
  "type": "module",
@@ -29,6 +29,24 @@ entity_property(_, severity, atom).
29
29
  entity_property(_, links, list).
30
30
  entity_property(_, text_ref, uri).
31
31
 
32
+ % Typed fact fields - only valid for fact entities
33
+ entity_property(fact, fact_kind, atom).
34
+ entity_property(fact, subject_key, string).
35
+ entity_property(fact, property_key, string).
36
+ entity_property(fact, operator, atom).
37
+ entity_property(fact, value_type, atom).
38
+ entity_property(fact, value_string, string).
39
+ entity_property(fact, value_int, integer).
40
+ entity_property(fact, value_number, number).
41
+ entity_property(fact, value_bool, boolean).
42
+ entity_property(fact, unit, string).
43
+ entity_property(fact, scope, string).
44
+ entity_property(fact, polarity, atom).
45
+ entity_property(fact, closed_world, boolean).
46
+ entity_property(fact, valid_from, datetime).
47
+ entity_property(fact, valid_to, datetime).
48
+ entity_property(fact, canonical_key, string).
49
+
32
50
  % Required properties for all entity types
33
51
  required_property(Type, id) :- entity_type(Type).
34
52
  required_property(Type, title) :- entity_type(Type).
@@ -17,7 +17,9 @@ validate_entity(Type, Props) :-
17
17
  % required properties present
18
18
  forall(required_property(Type, P), memberchk(P=_Val, Props)),
19
19
  % all properties have correct types
20
- forall(member(Key=Val, Props), validate_property_type(Type, Key, Val)).
20
+ forall(member(Key=Val, Props), validate_property_type(Type, Key, Val)),
21
+ % validate entity-specific shape constraints
22
+ validate_entity_shape(Type, Props).
21
23
 
22
24
  % validate_relationship(+RelType, +From, +To)
23
25
  % From and To are pairs Type=Id or structures type(Type) - allow Type or Type=Id
@@ -33,9 +35,8 @@ type_of(Type, Type) :- atom(Type), entity_type(Type), !.
33
35
  type_of(Type=_Id, Type) :- atom(Type), entity_type(Type), !.
34
36
 
35
37
  % validate_property_type(+EntityType, +Prop, +Value)
36
- validate_property_type(_Type, Prop, Value) :-
37
- % find declared property type, default to atom
38
- ( entity_property(_Any, Prop, Kind) -> true ; Kind = atom ),
38
+ validate_property_type(Type, Prop, Value) :-
39
+ entity_property(Type, Prop, Kind),
39
40
  check_kind(Kind, Value), !.
40
41
 
41
42
  % check_kind(Kind, Value) succeeds if Value matches Kind
@@ -44,6 +45,115 @@ check_kind(string, V) :- string(V).
44
45
  check_kind(datetime, V) :- string(V). % accept ISO strings for now
45
46
  check_kind(list, V) :- is_list(V).
46
47
  check_kind(uri, V) :- string(V).
48
+ check_kind(integer, V) :- integer(V).
49
+ check_kind(number, V) :- number(V).
50
+ check_kind(boolean, true).
51
+ check_kind(boolean, false).
47
52
 
48
53
  % Fallback false
49
54
  check_kind(_, _) :- fail.
55
+
56
+ % validate_entity_shape(+Type, +Props)
57
+ % Validates entity-specific shape constraints (e.g., strict fact shapes)
58
+ validate_entity_shape(fact, Props) :-
59
+ !,
60
+ valid_optional_fact_enums(Props),
61
+ valid_polarity_in_props(Props),
62
+ ( memberchk(fact_kind=RawKind, Props) -> validate_fact_shape(RawKind, Props) ; true ).
63
+ validate_entity_shape(Type, Props) :-
64
+ Type \= fact,
65
+ % Non-fact entities cannot have fact-only fields
66
+ forall(member(Key=_Val, Props), \+ is_fact_only_field(Key)).
67
+
68
+ % is_fact_only_field(+Key) - true if Key is a fact-specific field
69
+ is_fact_only_field(fact_kind).
70
+ is_fact_only_field(subject_key).
71
+ is_fact_only_field(property_key).
72
+ is_fact_only_field(operator).
73
+ is_fact_only_field(value_type).
74
+ is_fact_only_field(value_string).
75
+ is_fact_only_field(value_int).
76
+ is_fact_only_field(value_number).
77
+ is_fact_only_field(value_bool).
78
+ is_fact_only_field(unit).
79
+ is_fact_only_field(scope).
80
+ is_fact_only_field(polarity).
81
+ is_fact_only_field(closed_world).
82
+ is_fact_only_field(valid_from).
83
+ is_fact_only_field(valid_to).
84
+ is_fact_only_field(canonical_key).
85
+
86
+ % validate_fact_shape(+Kind, +Props)
87
+ validate_fact_shape(subject, Props) :-
88
+ memberchk(subject_key=_Val, Props),
89
+ valid_optional_fact_enums(Props),
90
+ valid_polarity_in_props(Props).
91
+ validate_fact_shape(property_value, Props) :-
92
+ memberchk(subject_key=_Subject, Props),
93
+ memberchk(property_key=_Property, Props),
94
+ memberchk(operator=Op, Props),
95
+ valid_operator(Op),
96
+ memberchk(value_type=VT, Props),
97
+ valid_value_type(VT),
98
+ exactly_one_value_field(Props),
99
+ value_type_matches_field(VT, Props),
100
+ valid_optional_fact_enums(Props),
101
+ valid_polarity_in_props(Props).
102
+ validate_fact_shape(observation, Props) :-
103
+ valid_optional_fact_enums(Props),
104
+ valid_polarity_in_props(Props).
105
+ validate_fact_shape(meta, Props) :-
106
+ % Meta facts are allowed but don't require full strict property tuple yet
107
+ valid_optional_fact_enums(Props),
108
+ valid_polarity_in_props(Props).
109
+ validate_fact_shape(Kind, _Props) :-
110
+ % Unknown fact_kind values fail validation
111
+ \+ memberchk(Kind, [subject, property_value, observation, meta]),
112
+ fail.
113
+
114
+ % valid_operator(+Op)
115
+ valid_operator(eq).
116
+ valid_operator(neq).
117
+ valid_operator(lt).
118
+ valid_operator(lte).
119
+ valid_operator(gt).
120
+ valid_operator(gte).
121
+
122
+ % valid_value_type(+VT)
123
+ valid_value_type(string).
124
+ valid_value_type(int).
125
+ valid_value_type(number).
126
+ valid_value_type(bool).
127
+
128
+ % valid_polarity_in_props(+Props)
129
+ % Validates polarity if present; succeeds if no polarity in props
130
+ valid_polarity_in_props(Props) :-
131
+ ( memberchk(polarity=P, Props) -> valid_polarity(P) ; true ).
132
+
133
+ % valid_optional_fact_enums(+Props)
134
+ % Validates enum-typed fact fields whenever they are present
135
+ valid_optional_fact_enums(Props) :-
136
+ ( memberchk(operator=Op, Props) -> valid_operator(Op) ; true ),
137
+ ( memberchk(value_type=VT, Props) -> valid_value_type(VT) ; true ).
138
+
139
+ % valid_polarity(+P)
140
+ valid_polarity(require).
141
+ valid_polarity(forbid).
142
+
143
+ % exactly_one_value_field(+Props)
144
+ exactly_one_value_field(Props) :-
145
+ findall(F, (member(F=_, Props), is_value_field(F)), Fields),
146
+ length(Fields, 1).
147
+
148
+ % is_value_field(+Field)
149
+ is_value_field(value_string).
150
+ is_value_field(value_int).
151
+ is_value_field(value_number).
152
+ is_value_field(value_bool).
153
+
154
+ % value_type_matches_field(+ValueType, +Props)
155
+ % Ensures that value_type matches the actual value field present
156
+ value_type_matches_field(string, Props) :- memberchk(value_string=_, Props), !.
157
+ value_type_matches_field(int, Props) :- memberchk(value_int=_, Props), !.
158
+ value_type_matches_field(number, Props) :- memberchk(value_number=_, Props), !.
159
+ value_type_matches_field(bool, Props) :- memberchk(value_bool=_, Props), !.
package/src/checks.pl CHANGED
@@ -8,12 +8,13 @@
8
8
  check_all_json/1, % Returns all violations as JSON string
9
9
  check_must_priority_coverage/1, % Returns list of must-priority violations
10
10
  check_symbol_coverage/1, % Returns list of uncovered symbols
11
- check_symbol_traceability/2, % Returns list of symbols lacking direct traceability (ReqAdr option)
11
+ check_symbol_traceability/2, % Returns list of symbols lacking requirement traceability (ReqAdr option)
12
12
  check_no_dangling_refs/1, % Returns list of dangling ref violations
13
13
  check_no_cycles/1, % Returns list of cycle violations
14
14
  check_required_fields/1, % Returns list of missing required field violations
15
15
  check_deprecated_adrs/1, % Returns list of deprecated ADR violations
16
16
  check_domain_contradictions/1, % Returns list of contradiction violations
17
+ check_strict_fact_shape/1, % Returns list of malformed strict fact violations
17
18
  run_checks_json/0 % Entry point for JSON output
18
19
  ]).
19
20
 
@@ -45,6 +46,7 @@ check_all(ViolationsDict) :-
45
46
  check_required_fields(RequiredFields),
46
47
  check_deprecated_adrs(DeprecatedADRs),
47
48
  check_domain_contradictions(Contradictions),
49
+ check_strict_fact_shape(StrictFactShape),
48
50
  ViolationsDict = _{
49
51
  must_priority_coverage: MustPriority,
50
52
  symbol_coverage: SymbolCoverage,
@@ -53,7 +55,8 @@ check_all(ViolationsDict) :-
53
55
  no_cycles: Cycles,
54
56
  required_fields: RequiredFields,
55
57
  deprecated_adr_no_successor: DeprecatedADRs,
56
- domain_contradictions: Contradictions
58
+ domain_contradictions: Contradictions,
59
+ strict_fact_shape: StrictFactShape
57
60
  }.
58
61
 
59
62
  %% check_must_priority_coverage(-Violations)
@@ -103,8 +106,8 @@ symbol_coverage_violation(SymbolId, violation(
103
106
  violation_source(SymbolId, symbol, Source).
104
107
 
105
108
  %% check_symbol_traceability(+RequireAdr, -Violations)
106
- % Finds all symbols lacking direct traceability:
107
- % - Every symbol must have at least one direct 'implements' relationship to a requirement
109
+ % Finds all symbols lacking supported requirement traceability:
110
+ % - Every symbol must have at least one supported requirement traceability path
108
111
  % - If RequireAdr=true, the symbol must also have at least one 'constrained_by' relationship to an ADR
109
112
  check_symbol_traceability(RequireAdr, Violations) :-
110
113
  findall(
@@ -122,8 +125,8 @@ symbol_traceability_violation(RequireAdr, violation(
122
125
  Source
123
126
  )) :-
124
127
  kb_entity(SymbolId, symbol, _),
125
- % Check if symbol has direct implements to a requirement
126
- ( kb_relationship(implements, SymbolId, ReqId),
128
+ % Check if symbol has a supported requirement traceability path
129
+ ( transitively_implements(SymbolId, ReqId),
127
130
  kb_entity(ReqId, req, _)
128
131
  -> HasReq = true
129
132
  ; HasReq = false
@@ -139,11 +142,11 @@ symbol_traceability_violation(RequireAdr, violation(
139
142
  ),
140
143
  % Determine what is missing
141
144
  ( HasReq = false, HasAdr = false, RequireAdr = true ->
142
- Description = "Symbol has no direct requirement link and no ADR constraint.",
143
- Suggestion = "Add 'implements: REQ-xxx' and 'constrained_by: ADR-xxx' in symbols.yaml."
145
+ Description = "Symbol has no supported requirement traceability path and no ADR constraint.",
146
+ Suggestion = "Add a direct 'implements: REQ-xxx' link or a test-backed 'covered_by' + 'validates'/'verified_by' path, and add 'constrained_by: ADR-xxx' in symbols.yaml."
144
147
  ; HasReq = false ->
145
- Description = "Symbol has no direct requirement link.",
146
- Suggestion = "Add 'implements: REQ-xxx' in symbols.yaml."
148
+ Description = "Symbol has no supported requirement traceability path.",
149
+ Suggestion = "Add a direct 'implements: REQ-xxx' link or a test-backed 'covered_by' + 'validates'/'verified_by' path."
147
150
  ; HasAdr = false ->
148
151
  Description = "Symbol has no ADR constraint.",
149
152
  Suggestion = "Add 'constrained_by: ADR-xxx' in symbols.yaml."
@@ -334,6 +337,95 @@ deprecated_adr_violation(violation(
334
337
  ; Source = ""
335
338
  ).
336
339
 
340
+ %% check_strict_fact_shape(-Violations)
341
+ % Finds all strict facts (with fact_kind) that have malformed shape.
342
+ % Only checks facts with fact_kind present; legacy facts without fact_kind are ignored.
343
+ % implements REQ-006
344
+ check_strict_fact_shape(Violations) :-
345
+ findall(
346
+ Violation,
347
+ strict_fact_shape_violation(Violation),
348
+ Violations0
349
+ ),
350
+ sort(Violations0, Violations).
351
+
352
+ strict_fact_shape_violation(violation(
353
+ 'strict-fact-shape',
354
+ FactId,
355
+ Description,
356
+ Suggestion,
357
+ Source
358
+ )) :-
359
+ kb_entity(FactId, fact, Props),
360
+ memberchk(fact_kind=RawKind, Props), % Only check facts with fact_kind
361
+ normalize_term_atom(RawKind, Kind), % Handle typed literals like ^^(subject, xsd:string)
362
+
363
+ % Check for malformed shape based on fact kind
364
+ ( Kind = subject
365
+ -> ( memberchk(subject_key=_, Props)
366
+ -> fail % Well-formed, no violation
367
+ ; Description = "Subject fact missing required field: subject_key",
368
+ Suggestion = "Add subject_key to define the subject domain key"
369
+ )
370
+ ; Kind = property_value
371
+ -> findall(Msg, property_value_shape_error(Props, Msg), Errors),
372
+ ( Errors = []
373
+ -> fail % Well-formed, no violation
374
+ ; Errors = [First|_],
375
+ Description = First,
376
+ Suggestion = "Ensure property_value facts have subject_key, property_key, operator, value_type, and exactly one value field"
377
+ )
378
+ ; Kind = observation
379
+ -> fail % Observation facts have no required fields beyond fact_kind
380
+ ; Kind = meta
381
+ -> fail % Meta facts have no required fields beyond fact_kind
382
+ ; % Unknown fact_kind - report as malformed
383
+ format(string(Description), "Unknown fact_kind: ~w", [Kind]),
384
+ Suggestion = "Use one of: subject, property_value, observation, meta"
385
+ ),
386
+
387
+ ( memberchk(source=Source0, Props)
388
+ -> normalize_term_atom(Source0, Source)
389
+ ; Source = ""
390
+ ).
391
+
392
+ % property_value_shape_error(+Props, -ErrorMsg)
393
+ % Returns an error message if the property_value fact has a shape error.
394
+ property_value_shape_error(Props, "Property value fact missing required field: subject_key") :-
395
+ \+ memberchk(subject_key=_, Props).
396
+ property_value_shape_error(Props, "Property value fact missing required field: property_key") :-
397
+ memberchk(subject_key=_, Props),
398
+ \+ memberchk(property_key=_, Props).
399
+ property_value_shape_error(Props, "Property value fact missing required field: operator") :-
400
+ memberchk(property_key=_, Props),
401
+ \+ memberchk(operator=_, Props).
402
+ property_value_shape_error(Props, "Property value fact missing required field: value_type") :-
403
+ memberchk(operator=_, Props),
404
+ \+ memberchk(value_type=_, Props).
405
+ property_value_shape_error(Props, "Property value fact missing value field (value_string, value_int, value_number, or value_bool)") :-
406
+ memberchk(value_type=_, Props),
407
+ \+ (memberchk(value_string=_, Props); memberchk(value_int=_, Props);
408
+ memberchk(value_number=_, Props); memberchk(value_bool=_, Props)).
409
+ property_value_shape_error(Props, "Property value fact has multiple value fields (should have exactly one)") :-
410
+ findall(F, (member(F=_, Props), is_value_field(F)), Fields),
411
+ length(Fields, Count),
412
+ Count > 1.
413
+ property_value_shape_error(Props, "Property value fact value_type does not match value field") :-
414
+ memberchk(value_type=VT, Props),
415
+ \+ value_type_matches_field(VT, Props).
416
+
417
+ % is_value_field(+Field)
418
+ is_value_field(value_string).
419
+ is_value_field(value_int).
420
+ is_value_field(value_number).
421
+ is_value_field(value_bool).
422
+
423
+ % value_type_matches_field(+ValueType, +Props)
424
+ value_type_matches_field(string, Props) :- memberchk(value_string=_, Props), !.
425
+ value_type_matches_field(int, Props) :- memberchk(value_int=_, Props), !.
426
+ value_type_matches_field(number, Props) :- memberchk(value_number=_, Props), !.
427
+ value_type_matches_field(bool, Props) :- memberchk(value_bool=_, Props), !.
428
+
337
429
  %% check_domain_contradictions(-Violations)
338
430
  % Finds all pairs of requirements with contradicting required properties.
339
431
  check_domain_contradictions(Violations) :-
@@ -398,6 +490,7 @@ check_all_with_options(ViolationsDict, RequireAdr) :-
398
490
  check_required_fields(RequiredFields),
399
491
  check_deprecated_adrs(DeprecatedADRs),
400
492
  check_domain_contradictions(Contradictions),
493
+ check_strict_fact_shape(StrictFactShape),
401
494
  ViolationsDict = _{
402
495
  must_priority_coverage: MustPriority,
403
496
  symbol_coverage: SymbolCoverage,
@@ -406,7 +499,8 @@ check_all_with_options(ViolationsDict, RequireAdr) :-
406
499
  no_cycles: Cycles,
407
500
  required_fields: RequiredFields,
408
501
  deprecated_adr_no_successor: DeprecatedADRs,
409
- domain_contradictions: Contradictions
502
+ domain_contradictions: Contradictions,
503
+ strict_fact_shape: StrictFactShape
410
504
  }.
411
505
 
412
506
  %% violations_dict_to_json(+ViolationsDict, -JsonDict)
package/src/discovery.pl CHANGED
@@ -369,7 +369,7 @@ edge_step(Current, Relationships, both, Edge, Next) :-
369
369
  relationship_allowed([], Type) :-
370
370
  relationship_type(Type).
371
371
  relationship_allowed(Relationships, Type) :-
372
- memberchk(Type, Relationships).
372
+ member(Type, Relationships).
373
373
 
374
374
  keep_entity_type([], _Id).
375
375
  keep_entity_type(EntityTypes, Id) :-
package/src/kb.pl CHANGED
@@ -6,10 +6,14 @@
6
6
  kb_save/0,
7
7
  with_kb_mutex/1,
8
8
  kb_assert_entity/2,
9
+ kb_assert_entity_no_audit/2,
10
+ kb_log_entity_upsert/2,
9
11
  kb_retract_entity/1,
10
12
  kb_entity/3,
11
13
  kb_entities_by_source/2,
12
14
  kb_assert_relationship/4,
15
+ kb_assert_relationship_no_audit/4,
16
+ kb_log_relationship_upsert/4,
13
17
  kb_relationship/3,
14
18
  transitively_implements/2,
15
19
  transitively_depends/2,
@@ -27,6 +31,7 @@
27
31
  deprecated_no_successor/1,
28
32
  symbol_no_req_coverage/2,
29
33
  contradicting_reqs/3,
34
+ check_req_contradiction/1,
30
35
  normalize_term_atom/2,
31
36
  changeset/4, % Export for testing
32
37
  kb_uri/1
@@ -238,16 +243,24 @@ load_kb_pl_files(Directory) :-
238
243
  ).
239
244
 
240
245
  %% kb_assert_entity(+Type, +Properties)
241
- % Assert an entity into the KB with validation and audit logging.
246
+ % Assert an entity into the KB with audit logging.
242
247
  % Properties is a list of Key=Value pairs.
243
248
  kb_assert_entity(Type, Props) :-
249
+ kb_assert_entity_no_audit(Type, Props),
250
+ kb_log_entity_upsert(Type, Props).
251
+
252
+ %% kb_assert_entity_no_audit(+Type, +Properties)
253
+ % Assert an entity RDF payload without recording audit side effects.
254
+ % Used by write-gated MCP transactions so failed contradiction checks do not
255
+ % leave partial audit residue.
256
+ kb_assert_entity_no_audit(Type, Props) :-
244
257
  % Validate entity
245
258
  validate_entity(Type, Props),
246
259
  % Extract ID
247
260
  memberchk(id=Id, Props),
248
261
  % Get current graph
249
262
  kb_graph(Graph),
250
- % Execute with mutex protection
263
+ % Execute RDF operations with mutex protection
251
264
  with_kb_mutex((
252
265
  % Create entity URI using prefix notation for namespace expansion
253
266
  format(atom(EntityURI), 'kb:entity/~w', [Id]),
@@ -260,8 +273,14 @@ kb_assert_entity(Type, Props) :-
260
273
  forall(
261
274
  member(Key=Value, Props),
262
275
  store_property(EntityURI, Key, Value, Graph)
263
- ),
264
- % Log to audit
276
+ )
277
+ )).
278
+
279
+ %% kb_log_entity_upsert(+Type, +Properties)
280
+ % Append the audit entry for a successfully committed entity upsert.
281
+ kb_log_entity_upsert(Type, Props) :-
282
+ memberchk(id=Id, Props),
283
+ with_kb_mutex((
265
284
  get_time(Timestamp),
266
285
  format_time(atom(TS), '%FT%T%:z', Timestamp),
267
286
  assert_changeset(TS, upsert, Id, Type-Props)
@@ -347,7 +366,15 @@ source_value_atom(Value, Atom) :-
347
366
 
348
367
  %% kb_assert_relationship(+Type, +From, +To, +Metadata)
349
368
  % Assert a relationship between two entities with validation.
350
- kb_assert_relationship(RelType, FromId, ToId, _Metadata) :-
369
+ kb_assert_relationship(RelType, FromId, ToId, Metadata) :-
370
+ kb_assert_relationship_no_audit(RelType, FromId, ToId, Metadata),
371
+ kb_log_relationship_upsert(RelType, FromId, ToId, Metadata).
372
+
373
+ %% kb_assert_relationship_no_audit(+Type, +From, +To, +Metadata)
374
+ % Assert a relationship RDF payload without recording audit side effects.
375
+ % Used by write-gated MCP transactions so failed contradiction checks do not
376
+ % leave partial audit residue.
377
+ kb_assert_relationship_no_audit(RelType, FromId, ToId, _Metadata) :-
351
378
  kb_graph(Graph),
352
379
  % Validate entities exist and relationship is valid
353
380
  % Use once/1 to keep this predicate deterministic even if the store
@@ -355,7 +382,11 @@ kb_assert_relationship(RelType, FromId, ToId, _Metadata) :-
355
382
  once(kb_entity(FromId, FromType, _)),
356
383
  once(kb_entity(ToId, ToType, _)),
357
384
  validate_relationship(RelType, FromType, ToType),
358
- % Execute with mutex protection
385
+ % NOTE: Strict-lane fact_kind pairing is validated at the MCP layer
386
+ % via validateStrictLanePairing() before the transaction begins.
387
+ % Prolog-level validation is deferred to avoid potential issues with
388
+ % rdf_transaction visibility and nondeterminism inside transactions.
389
+ % Execute RDF operations with mutex protection
359
390
  with_kb_mutex((
360
391
  % Create entity URIs
361
392
  atom_concat('kb:entity/', FromId, FromURI),
@@ -366,14 +397,56 @@ kb_assert_relationship(RelType, FromId, ToId, _Metadata) :-
366
397
  % Upsert semantics: ensure the exact triple isn't duplicated.
367
398
  rdf_retractall(FromURI, RelURI, ToURI, Graph),
368
399
  % Assert relationship triple
369
- rdf_assert(FromURI, RelURI, ToURI, Graph),
370
- % Log to audit
400
+ rdf_assert(FromURI, RelURI, ToURI, Graph)
401
+ )).
402
+
403
+ %% kb_log_relationship_upsert(+Type, +From, +To, +Metadata)
404
+ % Append the audit entry for a successfully committed relationship upsert.
405
+ kb_log_relationship_upsert(RelType, FromId, ToId, _Metadata) :-
406
+ with_kb_mutex((
371
407
  get_time(Timestamp),
372
408
  format_time(atom(TS), '%FT%T%:z', Timestamp),
373
409
  format(atom(RelId), '~w->~w', [FromId, ToId]),
374
410
  assert_changeset(TS, upsert_rel, RelId, RelType-[from=FromId, to=ToId])
375
411
  )).
376
412
 
413
+ %% validate_strict_lane_pairing(+RelType, +FromId, +ToId)
414
+ % Validate that constrains/requires_property relationships target facts
415
+ % with the correct fact_kind for strict-lane semantics.
416
+ % constrains targets must be subject facts (or legacy facts without fact_kind);
417
+ % requires_property targets must be property_value, observation, or meta facts
418
+ % (or legacy facts without fact_kind).
419
+ % implements REQ-011
420
+ validate_strict_lane_pairing(constrains, _FromId, ToId) :-
421
+ !,
422
+ ( % Allow: no fact_kind (legacy), subject, observation, or meta
423
+ kb_entity(ToId, fact, Props),
424
+ ( \+ memberchk(fact_kind=_, Props)
425
+ -> true % Legacy fact without fact_kind - allowed
426
+ ; memberchk(fact_kind=KindRaw, Props),
427
+ normalize_term_atom(KindRaw, Kind),
428
+ memberchk(Kind, [subject, observation, meta])
429
+ )
430
+ -> true
431
+ ; format(atom(Msg), 'constrains target ~w must be a subject, observation, or meta fact', [ToId]),
432
+ throw(error(validation_error(Msg), Msg))
433
+ ).
434
+ validate_strict_lane_pairing(requires_property, _FromId, ToId) :-
435
+ !,
436
+ ( % Allow: no fact_kind (legacy), property_value, observation, or meta
437
+ kb_entity(ToId, fact, Props),
438
+ ( \+ memberchk(fact_kind=_, Props)
439
+ -> true % Legacy fact without fact_kind - allowed
440
+ ; memberchk(fact_kind=KindRaw, Props),
441
+ normalize_term_atom(KindRaw, Kind),
442
+ memberchk(Kind, [property_value, observation, meta])
443
+ )
444
+ -> true
445
+ ; format(atom(Msg), 'requires_property target ~w must be a property_value, observation, or meta fact', [ToId]),
446
+ throw(error(validation_error(Msg), Msg))
447
+ ).
448
+ validate_strict_lane_pairing(_, _, _).
449
+
377
450
  %% kb_relationship(?Type, ?From, ?To)
378
451
  % Query relationships from the KB.
379
452
  kb_relationship(RelType, FromId, ToId) :-
@@ -393,17 +466,30 @@ kb_relationship(RelType, FromId, ToId) :-
393
466
  % Store a property as an RDF triple with appropriate datatype.
394
467
  % All values are stored as typed string literals to avoid URI interpretation issues.
395
468
  % Uses prefix notation (kb:Key) to enable proper namespace expansion.
469
+ % Typed fact fields (value_int, value_number, value_bool, closed_world) are stored
470
+ % with their appropriate XSD datatypes for round-trip preservation.
396
471
  store_property(EntityURI, Key, Value, Graph) :-
397
472
  % Build property URI using prefix notation for namespace expansion
398
473
  format(atom(PropURI), 'kb:~w', [Key]),
399
- % Always convert to literal (never store as URI/resource)
400
- value_to_literal(Value, Literal),
474
+ % Convert to literal with key-aware typing for typed fact fields
475
+ value_to_literal(Key, Value, Literal),
401
476
  rdf_assert(EntityURI, PropURI, Literal, Graph).
402
477
 
403
- %% value_to_literal(+Value, -Literal)
478
+ %% value_to_literal(+Key, +Value, -Literal)
404
479
  % Convert Prolog value to RDF literal with appropriate datatype.
405
- value_to_literal(Value, Literal) :-
406
- ( string(Value)
480
+ % Key-aware: typed fact fields get specific XSD datatypes.
481
+ value_to_literal(Key, Value, Literal) :-
482
+ % Typed fact fields with specific XSD datatypes
483
+ ( Key == value_int, integer(Value)
484
+ -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#integer'
485
+ ; Key == value_number, number(Value)
486
+ -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#decimal'
487
+ ; Key == value_bool, (Value == true ; Value == false)
488
+ -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#boolean'
489
+ ; Key == closed_world, (Value == true ; Value == false)
490
+ -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#boolean'
491
+ % Default: string or atom values as XSD string
492
+ ; string(Value)
407
493
  -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#string'
408
494
  ; is_list(Value)
409
495
  -> format(atom(ListStr), '~w', [Value]),
@@ -674,18 +760,217 @@ current_req(Id) :-
674
760
  \+ kb_relationship(supersedes, _, Id).
675
761
 
676
762
  %% contradicting_reqs(-ReqA, -ReqB, -Reason)
677
- % Two current requirements contradict if they constrain the same fact
678
- % but require different properties.
763
+ % Two current requirements contradict if they constrain facts that have
764
+ % semantic conflicts (same subject/property but incompatible values or polarities).
765
+ % Checks in order of specificity: polarity conflicts, then value conflicts.
679
766
  contradicting_reqs(ReqA, ReqB, Reason) :-
680
767
  current_req(ReqA),
681
768
  current_req(ReqB),
682
769
  ReqA @< ReqB,
683
- kb_relationship(constrains, ReqA, FactId),
684
- kb_relationship(constrains, ReqB, FactId),
685
- kb_relationship(requires_property, ReqA, PropA),
686
- kb_relationship(requires_property, ReqB, PropB),
687
- PropA \= PropB,
688
- format(atom(Reason), 'Conflict on ~w: ~w vs ~w', [FactId, PropA, PropB]).
770
+ req_conflict(ReqA, ReqB, Reason).
771
+
772
+ %% ------------------------------------------------------------------
773
+ %% Semantic Contradiction Helpers (Task 4)
774
+ %% ------------------------------------------------------------------
775
+
776
+ %% req_conflict(+ReqA, +ReqB, -Reason)
777
+ % Detects conflicts between requirements via shared subject facts and
778
+ % incompatible property_value facts linked through requires_property.
779
+ req_conflict(ReqA, ReqB, Reason) :-
780
+ kb_relationship(constrains, ReqA, SubjectFactA),
781
+ kb_relationship(constrains, ReqB, SubjectFactB),
782
+ fact_subject_key(SubjectFactA, SubjectKey),
783
+ fact_subject_key(SubjectFactB, SubjectKey),
784
+ effective_req_property_fact(ReqA, SubjectKey, FactA, PropertyKey, OpA, ValTypeA, ValA, UnitA, ScopeA, PolarityA, ValidFromA, ValidToA),
785
+ effective_req_property_fact(ReqB, SubjectKey, FactB, PropertyKey, OpB, ValTypeB, ValB, UnitB, ScopeB, PolarityB, ValidFromB, ValidToB),
786
+ FactA \= FactB,
787
+ scope_intersects(ScopeA, ScopeB),
788
+ intervals_overlap(ValidFromA, ValidToA, ValidFromB, ValidToB),
789
+ ( polarity_conflict(SubjectKey, PropertyKey, OpA, ValTypeA, ValA, UnitA, ScopeA, PolarityA,
790
+ OpB, ValTypeB, ValB, UnitB, ScopeB, PolarityB, Reason)
791
+ ; property_conflict(SubjectKey, PropertyKey, OpA, ValTypeA, ValA, UnitA, PolarityA,
792
+ OpB, ValTypeB, ValB, UnitB, PolarityB, Reason)
793
+ ).
794
+
795
+ %% fact_subject_key(+FactId, -SubjectKey)
796
+ % Extract the normalized subject key for strict subject facts.
797
+ fact_subject_key(FactId, SubjectKey) :-
798
+ kb_entity(FactId, fact, Props),
799
+ memberchk(fact_kind=KindRaw, Props),
800
+ normalize_term_atom(KindRaw, Kind),
801
+ Kind = subject,
802
+ memberchk(subject_key=SubjectRaw, Props),
803
+ normalize_term_atom(SubjectRaw, SubjectKey).
804
+
805
+ %% fact_property_tuple(+FactId, -Subject, -Property, -Op, -ValType, -Value, -Unit, -Scope, -Polarity)
806
+ % Extract typed property_value fact properties with defaults.
807
+ fact_property_tuple(FactId, Subject, Property, Op, ValType, Value, Unit, Scope, Polarity) :-
808
+ kb_entity(FactId, fact, Props),
809
+ memberchk(fact_kind=KindRaw, Props),
810
+ normalize_term_atom(KindRaw, Kind),
811
+ Kind = property_value,
812
+ memberchk(subject_key=SubjectRaw, Props),
813
+ normalize_term_atom(SubjectRaw, Subject),
814
+ memberchk(property_key=PropertyRaw, Props),
815
+ normalize_term_atom(PropertyRaw, Property),
816
+ ( memberchk(operator=OpRaw, Props) -> normalize_term_atom(OpRaw, Op) ; Op = eq ),
817
+ ( memberchk(value_type=ValTypeRaw, Props) -> normalize_term_atom(ValTypeRaw, ValType) ; ValType = string ),
818
+ value_from_props(Props, ValType, Value),
819
+ ( memberchk(unit=UnitRaw, Props) -> normalize_term_atom(UnitRaw, Unit) ; Unit = '' ),
820
+ ( memberchk(scope=ScopeRaw, Props) -> normalize_term_atom(ScopeRaw, Scope) ; Scope = '' ),
821
+ ( memberchk(polarity=PolarityRaw, Props) -> normalize_term_atom(PolarityRaw, Polarity) ; Polarity = require ).
822
+
823
+ %% fact_valid_interval(+FactId, -ValidFrom, -ValidTo)
824
+ % Extract optional validity bounds for property_value facts.
825
+ fact_valid_interval(FactId, ValidFrom, ValidTo) :-
826
+ kb_entity(FactId, fact, Props),
827
+ ( memberchk(valid_from=FromRaw, Props) -> normalize_term_atom(FromRaw, ValidFrom) ; ValidFrom = '' ),
828
+ ( memberchk(valid_to=ToRaw, Props) -> normalize_term_atom(ToRaw, ValidTo) ; ValidTo = '' ).
829
+
830
+ %% effective_req_property(+ReqId, -SubjectKey, -PropertyKey, -Operator, -ValueType, -Value, -Unit, -Scope, -Polarity)
831
+ % Effective strict property constraint for a current requirement.
832
+ effective_req_property(ReqId, SubjectKey, PropertyKey, Operator, ValueType, Value, Unit, Scope, Polarity) :-
833
+ effective_req_property_fact(ReqId, SubjectKey, _FactId, PropertyKey, Operator, ValueType, Value, Unit, Scope, Polarity, _ValidFrom, _ValidTo).
834
+
835
+ %% effective_req_property_fact(+ReqId, -SubjectKey, -FactId, -PropertyKey, -Operator, -ValueType, -Value, -Unit, -Scope, -Polarity, -ValidFrom, -ValidTo)
836
+ % Internal helper retaining the source property fact and validity window.
837
+ effective_req_property_fact(ReqId, SubjectKey, FactId, PropertyKey, Operator, ValueType, Value, Unit, Scope, Polarity, ValidFrom, ValidTo) :-
838
+ kb_relationship(constrains, ReqId, SubjectFactId),
839
+ fact_subject_key(SubjectFactId, SubjectKey),
840
+ kb_relationship(requires_property, ReqId, FactId),
841
+ fact_property_tuple(FactId, PropertySubjectKey, PropertyKey, Operator, ValueType, Value, Unit, Scope, Polarity),
842
+ PropertySubjectKey = SubjectKey,
843
+ fact_valid_interval(FactId, ValidFrom, ValidTo).
844
+
845
+ %% value_from_props(+Props, +ValType, -Value)
846
+ % Extract the appropriate value field based on value_type.
847
+ % Handles RDF literal values (^^(Value, Type)) by unwrapping them.
848
+ value_from_props(Props, string, Value) :- memberchk(value_string=ValueRaw, Props), !, unwrap_rdf_value(ValueRaw, Value).
849
+ value_from_props(Props, int, Value) :- memberchk(value_int=ValueRaw, Props), !, unwrap_rdf_value(ValueRaw, Value).
850
+ value_from_props(Props, number, Value) :- memberchk(value_number=ValueRaw, Props), !, unwrap_rdf_value(ValueRaw, Value).
851
+ value_from_props(Props, bool, Value) :- memberchk(value_bool=ValueRaw, Props), !, unwrap_rdf_value(ValueRaw, Value).
852
+ value_from_props(_, _, '').
853
+
854
+ %% unwrap_rdf_value(+Raw, -Value)
855
+ % Unwrap RDF literal ^^(Value, Type) to raw value.
856
+ unwrap_rdf_value(^^(Value, _Type), Value) :- !.
857
+ unwrap_rdf_value(Value, Value).
858
+
859
+ %% polarity_conflict(..., -Reason)
860
+ % Detect require vs forbid only when the rest of the normalized tuple matches.
861
+ polarity_conflict(Subject, Property, OpA, TypeA, ValA, UnitA, ScopeA, require,
862
+ OpB, TypeB, ValB, UnitB, ScopeB, forbid, Reason) :-
863
+ compatible_types(TypeA, TypeB),
864
+ unit_compatible(UnitA, UnitB),
865
+ scope_intersects(ScopeA, ScopeB),
866
+ OpA == OpB,
867
+ same_value(TypeA, ValA, TypeB, ValB),
868
+ !,
869
+ format(atom(Reason), 'Polarity conflict on ~w.~w: ~w ~w vs forbid', [Subject, Property, OpA, ValA]).
870
+ polarity_conflict(Subject, Property, OpA, TypeA, ValA, UnitA, ScopeA, forbid,
871
+ OpB, TypeB, ValB, UnitB, ScopeB, require, Reason) :-
872
+ compatible_types(TypeA, TypeB),
873
+ unit_compatible(UnitA, UnitB),
874
+ scope_intersects(ScopeA, ScopeB),
875
+ OpA == OpB,
876
+ same_value(TypeA, ValA, TypeB, ValB),
877
+ !,
878
+ format(atom(Reason), 'Polarity conflict on ~w.~w: forbid vs ~w ~w', [Subject, Property, OpB, ValB]).
879
+
880
+ %% property_conflict(..., -Reason)
881
+ % Detect value conflicts between two property constraints on the same subject/property.
882
+ property_conflict(Subject, Property, OpA, TypeA, ValA, UnitA, Polarity,
883
+ OpB, TypeB, ValB, UnitB, Polarity, Reason) :-
884
+ unit_compatible(UnitA, UnitB),
885
+ compatible_types(TypeA, TypeB),
886
+ values_conflict(OpA, ValA, OpB, ValB, TypeA),
887
+ format(atom(Reason), 'Value conflict on ~w.~w: ~w ~w vs ~w ~w', [Subject, Property, OpA, ValA, OpB, ValB]).
888
+
889
+ %% compatible_types(+TypeA, +TypeB)
890
+ % Types are compatible if they are the same or both numeric.
891
+ compatible_types(T, T) :- !.
892
+ compatible_types(int, number) :- !.
893
+ compatible_types(number, int) :- !.
894
+
895
+ %% unit_compatible(+UnitA, +UnitB)
896
+ % Units are compatible when equal or when one side is unspecified.
897
+ unit_compatible('', _) :- !.
898
+ unit_compatible(_, '') :- !.
899
+ unit_compatible(Unit, Unit).
900
+
901
+ %% scope_intersects(+ScopeA, +ScopeB)
902
+ % Scopes intersect when equal or when one side is unspecified.
903
+ scope_intersects('', _) :- !.
904
+ scope_intersects(_, '') :- !.
905
+ scope_intersects(Scope, Scope).
906
+
907
+ %% intervals_overlap(+FromA, +ToA, +FromB, +ToB)
908
+ % Validity windows overlap unless one ends strictly before the other begins.
909
+ intervals_overlap(FromA, ToA, FromB, ToB) :-
910
+ \+ interval_ends_before(ToA, FromB),
911
+ \+ interval_ends_before(ToB, FromA).
912
+
913
+ interval_ends_before('', _) :-
914
+ fail.
915
+ interval_ends_before(_, '') :-
916
+ fail.
917
+ interval_ends_before(To, From) :-
918
+ To @< From.
919
+
920
+ %% same_value(+TypeA, +ValA, +TypeB, +ValB)
921
+ % Compare scalar values with numeric coercion for int/number pairs.
922
+ same_value(TypeA, ValA, TypeB, ValB) :-
923
+ ( is_numeric_type(TypeA),
924
+ is_numeric_type(TypeB)
925
+ -> ValA =:= ValB
926
+ ; ValA == ValB
927
+ ).
928
+
929
+ %% values_conflict(+OpA, +ValA, +OpB, +ValB, +Type)
930
+ % Detect specific value conflicts based on operators and types.
931
+
932
+ % Exact value conflict: eq X vs eq Y where X \= Y
933
+ values_conflict(eq, ValA, eq, ValB, Type) :-
934
+ \+ same_value(Type, ValA, Type, ValB).
935
+
936
+ % Eq/neq conflict on the same scalar value.
937
+ values_conflict(eq, ValA, neq, ValB, Type) :-
938
+ same_value(Type, ValA, Type, ValB).
939
+ values_conflict(neq, ValA, eq, ValB, Type) :-
940
+ same_value(Type, ValA, Type, ValB).
941
+
942
+ % Numeric gap conflict: lte X vs gte Y where X < Y
943
+ values_conflict(lte, ValA, gte, ValB, Type) :-
944
+ is_numeric_type(Type),
945
+ ValA < ValB.
946
+ values_conflict(gte, ValB, lte, ValA, Type) :-
947
+ is_numeric_type(Type),
948
+ ValA < ValB.
949
+
950
+ % Also catch lt/gt variants
951
+ values_conflict(lt, ValA, gt, ValB, Type) :-
952
+ is_numeric_type(Type),
953
+ ValA =< ValB.
954
+ values_conflict(gt, ValB, lt, ValA, Type) :-
955
+ is_numeric_type(Type),
956
+ ValA =< ValB.
957
+ values_conflict(lt, ValA, gte, ValB, Type) :-
958
+ is_numeric_type(Type),
959
+ ValA =< ValB.
960
+ values_conflict(gte, ValB, lt, ValA, Type) :-
961
+ is_numeric_type(Type),
962
+ ValA =< ValB.
963
+ values_conflict(lte, ValA, gt, ValB, Type) :-
964
+ is_numeric_type(Type),
965
+ ValA < ValB.
966
+ values_conflict(gt, ValB, lte, ValA, Type) :-
967
+ is_numeric_type(Type),
968
+ ValA < ValB.
969
+
970
+ %% is_numeric_type(+Type)
971
+ % True for numeric value types.
972
+ is_numeric_type(int).
973
+ is_numeric_type(number).
689
974
 
690
975
  normalize_term_atom(Val^^_Type, Atom) :-
691
976
  !,
@@ -781,3 +1066,57 @@ changed_symbol_violation(Symbol, MinLinks, Count, File, Line, Col, Name) :-
781
1066
  -> File = FileRaw, Name = NameRaw
782
1067
  ; File = '', Line = 0, Col = 0, Name = ''
783
1068
  ).
1069
+
1070
+ %% check_req_contradiction(+ReqId)
1071
+ % Validates that a requirement has no contradictions with current requirements.
1072
+ % Called within a transaction after asserting entity and relationships.
1073
+ % Throws error(kb_contradiction(Details)) if contradictions are found.
1074
+ % implements REQ-011
1075
+ % This predicate first checks if the new requirement supersedes the specific
1076
+ % conflicting requirement(s). Only direct supersedes edges from the new req
1077
+ % to the conflicting req allow the write - unrelated supersedes edges do not
1078
+ % mask conflicts.
1079
+ check_req_contradiction(ReqId) :-
1080
+ % Collect all contradictions involving this requirement
1081
+ findall(Reason-OtherReq, (
1082
+ contradicting_reqs(ReqId, OtherReq, Reason)
1083
+ ; contradicting_reqs(OtherReq, ReqId, Reason)
1084
+ ), AllPairs),
1085
+ AllPairs \= [],
1086
+ !,
1087
+ % Filter out contradictions where ReqId directly supersedes the conflicting requirement
1088
+ % Only the specific supersedes edge from new req -> conflicting req allows the write
1089
+ exclude(superseded_by_contradiction(ReqId), AllPairs, ValidPairs),
1090
+ ValidPairs \= [],
1091
+ !,
1092
+ % Build actionable error message
1093
+ build_contradiction_message(ReqId, ValidPairs, Message),
1094
+ throw(error(kb_contradiction(ValidPairs), Message)).
1095
+ check_req_contradiction(_) :-
1096
+ % No contradictions found
1097
+ true.
1098
+
1099
+ %% supersedes_mask(+ReqId, +Reason-OtherReq)
1100
+ % True when ReqId supersedes OtherReq, meaning this specific contradiction is allowed.
1101
+ % Only direct supersedes edges from the new requirement to the conflicting
1102
+ % requirement mask the conflict - unrelated supersedes edges do not.
1103
+ % implements REQ-011
1104
+ superseded_by_contradiction(ReqId, _-OtherReq) :-
1105
+ kb_relationship(supersedes, ReqId, OtherReq).
1106
+
1107
+ %% build_contradiction_message(+ReqId, +Pairs, -Message)
1108
+ % Build an actionable error message for contradictions.
1109
+ % Message includes conflicting req IDs, subject/property details, and remediation hints.
1110
+ % implements REQ-011
1111
+ build_contradiction_message(ReqId, Pairs, Message) :-
1112
+ format(atom(Header), 'Contradiction detected for requirement ~w:', [ReqId]),
1113
+ build_contradiction_details(Pairs, Details),
1114
+ Remedy = '\n\nTo resolve:\n 1. Add a supersedes relationship from the new requirement to the conflicting one, OR\n 2. Deprecate the conflicting requirement before creating the new one.',
1115
+ atom_concat(Header, Details, Temp),
1116
+ atom_concat(Temp, Remedy, Message).
1117
+
1118
+ build_contradiction_details([], '').
1119
+ build_contradiction_details([Reason-OtherReq|Rest], Details) :-
1120
+ format(atom(Line), '\n - Conflicts with ~w: ~w', [OtherReq, Reason]),
1121
+ build_contradiction_details(Rest, RestDetails),
1122
+ atom_concat(Line, RestDetails, Details).
package/src/status.pl CHANGED
@@ -15,6 +15,7 @@ kb_status_json(JsonString) :-
15
15
 
16
16
  status_meta_dict(StatusDict) :-
17
17
  attached_kb_info(Branch, KbPath, DataFile),
18
+ !,
18
19
  snapshot_id(SnapshotId),
19
20
  synced_at(DataFile, SyncedAt),
20
21
  freshness_state(DataFile, Dirty, SyncState),
@@ -27,6 +28,18 @@ status_meta_dict(StatusDict) :-
27
28
  kbPath: KbPath,
28
29
  lastSyncSource: persisted
29
30
  }.
31
+ status_meta_dict(StatusDict) :-
32
+ % Fallback for non-standard KB paths (e.g. temp dirs in tests)
33
+ ( kb:kb_attached(KbPath) -> true ; KbPath = unknown ),
34
+ StatusDict = _{
35
+ branch: unknown,
36
+ snapshotId: unknown,
37
+ syncedAt: null,
38
+ dirty: false,
39
+ syncState: unknown,
40
+ kbPath: KbPath,
41
+ lastSyncSource: unknown
42
+ }.
30
43
 
31
44
  attached_kb_info(Branch, KbPath, DataFile) :-
32
45
  kb:kb_attached(KbPath),
@@ -81,7 +94,7 @@ workspace_source_changed(SnapshotTime) :-
81
94
  ),
82
95
  !.
83
96
 
84
- documentation_tree_changed(SnapshotTime) :-
97
+ documentation_tree_changed(_SnapshotTime) :-
85
98
  attached_workspace_root(WorkspaceRoot),
86
99
  documentation_markdown_untracked(WorkspaceRoot),
87
100
  !.