kibi-core 0.1.10 → 0.3.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/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
@@ -59,16 +64,15 @@ kb_uri('urn-kibi:').
59
64
  :- dynamic kb_attached/1.
60
65
  :- dynamic kb_audit_db/1.
61
66
  :- dynamic kb_graph/1.
67
+ :- dynamic kb_attached_snapshot/1.
62
68
  :- dynamic entity/4. % Support legacy .pl file format (Type, Id, Title, Props)
63
69
 
64
70
  %% kb_attach(+Directory)
65
71
  % Attach to a KB directory with RDF persistence and file locking.
66
72
  % Creates directory if it doesn't exist.
67
73
  kb_attach(Directory) :-
68
- % If we were already attached in this process, detach first.
69
- % This prevents accidentally loading the same RDF snapshot multiple times.
70
74
  ( kb_attached(_)
71
- -> kb_detach
75
+ -> throw(error(permission_error(attach, kb, Directory), kb_attach/1))
72
76
  ; true
73
77
  ),
74
78
  % Ensure directory exists
@@ -89,6 +93,7 @@ kb_attach(Directory) :-
89
93
  -> rdf_load(DataFile, [graph(GraphURI), silent(true)])
90
94
  ; true
91
95
  ),
96
+ current_data_stamp(DataFile, SnapshotStamp),
92
97
  % Set up audit log - only attach if not already attached
93
98
  atom_concat(Directory, '/audit.log', AuditLog),
94
99
  ( db_attached(AuditLog)
@@ -99,55 +104,114 @@ kb_attach(Directory) :-
99
104
  assert(kb_attached(Directory)),
100
105
  assert(kb_audit_db(AuditLog)),
101
106
  assert(kb_graph(GraphURI)),
107
+ assert(kb_attached_snapshot(SnapshotStamp)),
102
108
 
103
109
  % Load legacy .pl entity files if present
104
110
  load_kb_pl_files(Directory).
105
111
 
106
112
 
107
113
  %% kb_detach
108
- % Safely detach from KB, flushing journals and closing audit log.
114
+ % Safely detach from KB without persisting pending changes.
115
+ % Call kb_save/0 explicitly before kb_detach/0 when durability is required.
116
+ % implements REQ-009
109
117
  kb_detach :-
110
118
  ( kb_attached(_Directory)
111
119
  -> (
112
- kb_save,
113
120
  % Unload RDF graph from memory to prevent duplication on reattach
114
121
  ( kb_graph(GraphURI)
115
122
  -> rdf_unload_graph(GraphURI)
116
123
  ; true
117
124
  ),
118
- % Sync and close audit log
119
- ( kb_audit_db(AuditLog)
120
- -> db_sync(AuditLog)
125
+ ( db_attached(_)
126
+ -> catch(db_detach, _, true)
121
127
  ; true
122
128
  ),
123
129
  % Clear state
124
130
  retractall(kb_attached(_)),
125
131
  retractall(kb_audit_db(_)),
126
- retractall(kb_graph(_))
132
+ retractall(kb_graph(_)),
133
+ retractall(kb_attached_snapshot(_))
127
134
  )
128
135
  ; true
129
136
  ).
130
137
 
131
138
  %% kb_save
132
139
  % Save RDF graph and sync audit log to disk
140
+ % implements REQ-009
133
141
  kb_save :-
134
142
  ( kb_attached(Directory)
135
- -> (
136
- % Save RDF graph to file with namespace declarations
137
- atom_concat(Directory, '/kb.rdf', DataFile),
138
- % Get current graph URI
139
- kb_graph(GraphURI),
140
- % If we have a graph URI, save that graph. Otherwise save all data
141
- % (fallback) so a kb.rdf is always produced. Report errors if save fails.
142
- ( kb_graph(GraphURI)
143
- -> catch(rdf_save(DataFile, [graph(GraphURI), base_uri('urn-kibi:'), namespaces([kb, xsd])]), E, print_message(error, E))
144
- ; catch(rdf_save(DataFile, [base_uri('urn-kibi:'), namespaces([kb, xsd])]), E2, print_message(error, E2))
145
- ),
146
- % Sync audit log
147
- ( kb_audit_db(AuditLog)
148
- -> db_sync(AuditLog)
149
- ; true
150
- )
143
+ -> with_kb_mutex(with_kb_file_lock(Directory, kb_save_locked(Directory)))
144
+ ; true
145
+ ).
146
+
147
+ with_kb_file_lock(Directory, Goal) :-
148
+ atom_concat(Directory, '/kb.lock', LockFile),
149
+ setup_call_cleanup(
150
+ open(LockFile, append, LockStream, [lock(write)]),
151
+ call(Goal),
152
+ close(LockStream)
153
+ ).
154
+
155
+ kb_save_locked(Directory) :-
156
+ atom_concat(Directory, '/kb.rdf', DataFile),
157
+ temp_rdf_file(Directory, TempFile),
158
+ catch(
159
+ (
160
+ ensure_snapshot_current(DataFile),
161
+ save_rdf_snapshot(TempFile),
162
+ rename_file(TempFile, DataFile),
163
+ current_data_stamp(DataFile, UpdatedStamp),
164
+ retractall(kb_attached_snapshot(_)),
165
+ assert(kb_attached_snapshot(UpdatedStamp)),
166
+ sync_audit_log
167
+ ),
168
+ Error,
169
+ (
170
+ cleanup_temp_file(TempFile),
171
+ throw(Error)
172
+ )
173
+ ).
174
+
175
+ temp_rdf_file(Directory, TempFile) :-
176
+ get_time(Timestamp),
177
+ Millis is floor(Timestamp * 1000),
178
+ ( current_prolog_flag(pid, Pid)
179
+ -> true
180
+ ; Pid = 0
181
+ ),
182
+ format(atom(TempFile), '~w/kb.rdf.tmp.~w.~w', [Directory, Pid, Millis]).
183
+
184
+ save_rdf_snapshot(TargetFile) :-
185
+ ( kb_graph(GraphURI)
186
+ -> rdf_save(TargetFile, [graph(GraphURI), base_uri('urn-kibi:'), namespaces([kb, xsd])])
187
+ ; rdf_save(TargetFile, [base_uri('urn-kibi:'), namespaces([kb, xsd])])
188
+ ).
189
+
190
+ sync_audit_log :-
191
+ ( kb_audit_db(AuditLog)
192
+ -> db_sync(AuditLog)
193
+ ; true
194
+ ).
195
+
196
+ cleanup_temp_file(TempFile) :-
197
+ ( exists_file(TempFile)
198
+ -> catch(delete_file(TempFile), _, true)
199
+ ; true
200
+ ).
201
+
202
+ current_data_stamp(DataFile, missing) :-
203
+ \+ exists_file(DataFile),
204
+ !.
205
+ current_data_stamp(DataFile, stamp(MTime, Size)) :-
206
+ time_file(DataFile, MTime),
207
+ size_file(DataFile, Size).
208
+
209
+ ensure_snapshot_current(DataFile) :-
210
+ ( kb_attached_snapshot(ExpectedStamp)
211
+ -> current_data_stamp(DataFile, CurrentStamp),
212
+ ( ExpectedStamp == CurrentStamp
213
+ -> true
214
+ ; throw(error(permission_error(save, kb, stale_snapshot), kb_save/0))
151
215
  )
152
216
  ; true
153
217
  ).
@@ -179,16 +243,24 @@ load_kb_pl_files(Directory) :-
179
243
  ).
180
244
 
181
245
  %% kb_assert_entity(+Type, +Properties)
182
- % Assert an entity into the KB with validation and audit logging.
246
+ % Assert an entity into the KB with audit logging.
183
247
  % Properties is a list of Key=Value pairs.
184
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) :-
185
257
  % Validate entity
186
258
  validate_entity(Type, Props),
187
259
  % Extract ID
188
260
  memberchk(id=Id, Props),
189
261
  % Get current graph
190
262
  kb_graph(Graph),
191
- % Execute with mutex protection
263
+ % Execute RDF operations with mutex protection
192
264
  with_kb_mutex((
193
265
  % Create entity URI using prefix notation for namespace expansion
194
266
  format(atom(EntityURI), 'kb:entity/~w', [Id]),
@@ -201,8 +273,14 @@ kb_assert_entity(Type, Props) :-
201
273
  forall(
202
274
  member(Key=Value, Props),
203
275
  store_property(EntityURI, Key, Value, Graph)
204
- ),
205
- % 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((
206
284
  get_time(Timestamp),
207
285
  format_time(atom(TS), '%FT%T%:z', Timestamp),
208
286
  assert_changeset(TS, upsert, Id, Type-Props)
@@ -288,7 +366,15 @@ source_value_atom(Value, Atom) :-
288
366
 
289
367
  %% kb_assert_relationship(+Type, +From, +To, +Metadata)
290
368
  % Assert a relationship between two entities with validation.
291
- 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) :-
292
378
  kb_graph(Graph),
293
379
  % Validate entities exist and relationship is valid
294
380
  % Use once/1 to keep this predicate deterministic even if the store
@@ -296,7 +382,11 @@ kb_assert_relationship(RelType, FromId, ToId, _Metadata) :-
296
382
  once(kb_entity(FromId, FromType, _)),
297
383
  once(kb_entity(ToId, ToType, _)),
298
384
  validate_relationship(RelType, FromType, ToType),
299
- % 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
300
390
  with_kb_mutex((
301
391
  % Create entity URIs
302
392
  atom_concat('kb:entity/', FromId, FromURI),
@@ -307,14 +397,56 @@ kb_assert_relationship(RelType, FromId, ToId, _Metadata) :-
307
397
  % Upsert semantics: ensure the exact triple isn't duplicated.
308
398
  rdf_retractall(FromURI, RelURI, ToURI, Graph),
309
399
  % Assert relationship triple
310
- rdf_assert(FromURI, RelURI, ToURI, Graph),
311
- % 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((
312
407
  get_time(Timestamp),
313
408
  format_time(atom(TS), '%FT%T%:z', Timestamp),
314
409
  format(atom(RelId), '~w->~w', [FromId, ToId]),
315
410
  assert_changeset(TS, upsert_rel, RelId, RelType-[from=FromId, to=ToId])
316
411
  )).
317
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
+
318
450
  %% kb_relationship(?Type, ?From, ?To)
319
451
  % Query relationships from the KB.
320
452
  kb_relationship(RelType, FromId, ToId) :-
@@ -334,17 +466,30 @@ kb_relationship(RelType, FromId, ToId) :-
334
466
  % Store a property as an RDF triple with appropriate datatype.
335
467
  % All values are stored as typed string literals to avoid URI interpretation issues.
336
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.
337
471
  store_property(EntityURI, Key, Value, Graph) :-
338
472
  % Build property URI using prefix notation for namespace expansion
339
473
  format(atom(PropURI), 'kb:~w', [Key]),
340
- % Always convert to literal (never store as URI/resource)
341
- value_to_literal(Value, Literal),
474
+ % Convert to literal with key-aware typing for typed fact fields
475
+ value_to_literal(Key, Value, Literal),
342
476
  rdf_assert(EntityURI, PropURI, Literal, Graph).
343
477
 
344
- %% value_to_literal(+Value, -Literal)
478
+ %% value_to_literal(+Key, +Value, -Literal)
345
479
  % Convert Prolog value to RDF literal with appropriate datatype.
346
- value_to_literal(Value, Literal) :-
347
- ( 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)
348
493
  -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#string'
349
494
  ; is_list(Value)
350
495
  -> format(atom(ListStr), '~w', [Value]),
@@ -615,18 +760,217 @@ current_req(Id) :-
615
760
  \+ kb_relationship(supersedes, _, Id).
616
761
 
617
762
  %% contradicting_reqs(-ReqA, -ReqB, -Reason)
618
- % Two current requirements contradict if they constrain the same fact
619
- % 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.
620
766
  contradicting_reqs(ReqA, ReqB, Reason) :-
621
767
  current_req(ReqA),
622
768
  current_req(ReqB),
623
769
  ReqA @< ReqB,
624
- kb_relationship(constrains, ReqA, FactId),
625
- kb_relationship(constrains, ReqB, FactId),
626
- kb_relationship(requires_property, ReqA, PropA),
627
- kb_relationship(requires_property, ReqB, PropB),
628
- PropA \= PropB,
629
- 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).
630
974
 
631
975
  normalize_term_atom(Val^^_Type, Atom) :-
632
976
  !,
@@ -722,3 +1066,57 @@ changed_symbol_violation(Symbol, MinLinks, Count, File, Line, Col, Name) :-
722
1066
  -> File = FileRaw, Name = NameRaw
723
1067
  ; File = '', Line = 0, Col = 0, Name = ''
724
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).