kibi-core 0.8.1 → 0.10.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
@@ -4,13 +4,27 @@
4
4
  kb_attach/1,
5
5
  kb_detach/0,
6
6
  kb_save/0,
7
+ kb_sync_checkpoint/0,
8
+ kb_storage_status/1,
9
+ kb_storage_compact/0,
10
+ kb_storage_compact_if_needed/0,
11
+ kb_storage_export/1,
12
+ kb_migrate_legacy/2,
7
13
  with_kb_mutex/1,
8
14
  kb_assert_entity/2,
9
15
  kb_assert_entity_no_audit/2,
16
+ kb_commit_upsert/5,
10
17
  kb_log_entity_upsert/3,
11
18
  kb_retract_entity/1,
12
19
  kb_retract_entity/3,
13
20
  kb_retract_entity_relationships/1,
21
+ kb_retract_relationship/3,
22
+ kb_retract_all_relationships/0,
23
+ kb_entities_by_tag/2,
24
+ kb_indexed_sources/1,
25
+ kb_query_entities/8,
26
+ kb_search_entities/6,
27
+ kb_rebuild_indexes/0,
14
28
  kb_entity/3,
15
29
  kb_entities_by_source/2,
16
30
  kb_assert_relationship/4,
@@ -43,6 +57,7 @@
43
57
  predicate_schema/6,
44
58
  predicate_fact/5,
45
59
  contradicting_reqs/3,
60
+ req_conflict_witness/3,
46
61
  check_req_contradiction/1,
47
62
  normalize_term_atom/2,
48
63
  normalize_term_atom_list/2,
@@ -51,9 +66,13 @@
51
66
  ]).
52
67
 
53
68
  :- use_module(library(semweb/rdf11)).
69
+ :- use_module(library(semweb/rdf_persistency)).
54
70
  :- use_module(library(persistency)).
55
71
  :- use_module(library(thread)).
56
72
  :- use_module(library(filesex)).
73
+ :- use_module(library(readutil)).
74
+ :- use_module(library(aggregate), [aggregate_all/3]).
75
+ :- use_module(library(lists), [sum_list/2]).
57
76
  :- use_module(library(ordsets)).
58
77
  :- use_module('../schema/entities.pl', [entity_type/1, entity_property/3, required_property/2]).
59
78
  :- use_module('../schema/relationships.pl', [relationship_type/1, valid_relationship/3]).
@@ -78,10 +97,31 @@ kb_uri('urn-kibi:').
78
97
  :- dynamic kb_audit_db/1.
79
98
  :- dynamic kb_graph/1.
80
99
  :- dynamic kb_attached_snapshot/1.
100
+ :- dynamic kb_storage_mode/1. % legacy | journaled
101
+ :- dynamic kb_persistency_directory/1.
102
+ :- dynamic kb_commit_sequence/1.
103
+ :- dynamic kb_generation/1.
104
+ :- dynamic kb_legacy_sentinel_stamp/1.
105
+ :- dynamic kb_audit_sequence/1.
106
+ :- dynamic kb_dirty/0.
107
+ % Rebuildable read indexes. RDF remains authoritative; a dirty marker causes
108
+ % the next unbound discovery query to rebuild these daemon-lifetime indexes.
109
+ :- dynamic kb_index_ready/0.
110
+ :- dynamic kb_index_dirty/0.
111
+ :- dynamic kb_index_entity/2.
112
+ :- dynamic kb_index_type/2.
113
+ :- dynamic kb_index_tag/2.
114
+ :- dynamic kb_index_source/2.
115
+ :- dynamic kb_index_token/2.
116
+ :- dynamic kb_index_symbol_coordinate/5.
117
+ :- dynamic kb_index_entity_count/1.
118
+ :- dynamic kb_index_triple_count/1.
81
119
  :- dynamic entity/4. % Support legacy .pl file format (Type, Id, Title, Props)
82
120
 
83
121
  %% kb_attach(+Directory)
84
- % Attach to a KB directory with RDF persistence and file locking.
122
+ % Attach to a KB directory. New stores use SWI's journaled RDF persistence;
123
+ % unmarked directories retain the legacy RDF/XML path for migration and
124
+ % backwards-compatible temporary KBs.
85
125
  % Creates directory if it doesn't exist.
86
126
  kb_attach(Directory) :-
87
127
  ( kb_attached(_)
@@ -93,6 +133,67 @@ kb_attach(Directory) :-
93
133
  -> true
94
134
  ; make_directory_path(Directory)
95
135
  ),
136
+ ( journaled_store(Directory)
137
+ -> kb_attach_journaled(Directory)
138
+ ; kb_attach_legacy(Directory)
139
+ ).
140
+
141
+ journaled_store(Directory) :-
142
+ atom_concat(Directory, '/storage.json', Marker),
143
+ exists_file(Marker),
144
+ catch(
145
+ read_file_to_string(Marker, Contents, []),
146
+ _,
147
+ fail
148
+ ),
149
+ sub_string(Contents, _, _, _, "kibi.rdf-journal.v1").
150
+
151
+ % implements REQ-core-journaled-engine-persistence
152
+ kb_attach_journaled(Directory) :-
153
+ atom_concat(Directory, '/rdf', PersistencyDirectory),
154
+ ( exists_directory(PersistencyDirectory)
155
+ -> true
156
+ ; make_directory_path(PersistencyDirectory)
157
+ ),
158
+ % rdf_persistency owns the database lock for the lifetime of the engine.
159
+ rdf_attach_db(PersistencyDirectory,
160
+ [access(read_write), silent(true), concurrency(4)]),
161
+ % Create RDF graph name from directory. Do not unload a graph here:
162
+ % rdf_attach_db has already restored it from its snapshot/journal. A
163
+ % staged migration can have been atomically moved after the graph was
164
+ % first persisted, so accept the single restored graph URI as well.
165
+ journal_graph_uri(Directory, GraphURI),
166
+ read_journal_metadata(Directory, Generation, Sequence),
167
+ assert(kb_attached(Directory)),
168
+ assert(kb_graph(GraphURI)),
169
+ assert(kb_storage_mode(journaled)),
170
+ assert(kb_persistency_directory(PersistencyDirectory)),
171
+ assert(kb_generation(Generation)),
172
+ assert(kb_commit_sequence(Sequence)),
173
+ assert(kb_attached_snapshot(journal_snapshot(Generation, Sequence))),
174
+ retractall(kb_dirty),
175
+ initialize_journal_audit_sequence(GraphURI),
176
+ atom_concat(Directory, '/kb.rdf', SentinelFile),
177
+ current_data_stamp(SentinelFile, SentinelStamp),
178
+ assert(kb_legacy_sentinel_stamp(SentinelStamp)),
179
+
180
+ load_kb_pl_files(Directory),
181
+ kb_rebuild_indexes.
182
+
183
+ journal_graph_uri(Directory, GraphURI) :-
184
+ atom_concat('file://', Directory, Expected),
185
+ ( rdf_graph(Expected)
186
+ -> GraphURI = Expected
187
+ ; findall(G,
188
+ (rdf_graph(G), rdf(_, _, _, G)),
189
+ Graphs),
190
+ ( Graphs = [GraphURI|_]
191
+ -> true
192
+ ; GraphURI = Expected
193
+ )
194
+ ).
195
+
196
+ kb_attach_legacy(Directory) :-
96
197
  % Create RDF graph name from directory
97
198
  atom_concat('file://', Directory, GraphURI),
98
199
  % If a graph with this URI is already present, unload it to avoid duplicates.
@@ -111,16 +212,21 @@ kb_attach(Directory) :-
111
212
  atom_concat(Directory, '/audit.log', AuditLog),
112
213
  ( db_attached(AuditLog)
113
214
  -> true % Already attached
114
- ; db_attach(AuditLog, [])
215
+ ; % Close the journal stream after each write. The default `flush`
216
+ % mode retains an exclusive write lock for the lifetime of a
217
+ % long-running MCP process and blocks writers in other runtimes.
218
+ db_attach(AuditLog, [sync(close)])
115
219
  ),
116
220
  % Track attachment state
117
221
  assert(kb_attached(Directory)),
118
222
  assert(kb_audit_db(AuditLog)),
119
223
  assert(kb_graph(GraphURI)),
224
+ assert(kb_storage_mode(legacy)),
120
225
  assert(kb_attached_snapshot(SnapshotStamp)),
121
226
 
122
227
  % Load legacy .pl entity files if present
123
- load_kb_pl_files(Directory).
228
+ load_kb_pl_files(Directory),
229
+ kb_rebuild_indexes.
124
230
 
125
231
 
126
232
  %% kb_detach
@@ -130,6 +236,11 @@ kb_attach(Directory) :-
130
236
  kb_detach :-
131
237
  ( kb_attached(_Directory)
132
238
  -> (
239
+ ( kb_storage_mode(journaled)
240
+ -> catch(rdf_flush_journals([min_size(16384)]), _, true),
241
+ catch(rdf_detach_db, _, true)
242
+ ; true
243
+ ),
133
244
  % Unload RDF graph from memory to prevent duplication on reattach
134
245
  ( kb_graph(GraphURI)
135
246
  -> rdf_unload_graph(GraphURI)
@@ -143,7 +254,24 @@ kb_detach :-
143
254
  retractall(kb_attached(_)),
144
255
  retractall(kb_audit_db(_)),
145
256
  retractall(kb_graph(_)),
146
- retractall(kb_attached_snapshot(_))
257
+ retractall(kb_attached_snapshot(_)),
258
+ retractall(kb_storage_mode(_)),
259
+ retractall(kb_persistency_directory(_)),
260
+ retractall(kb_commit_sequence(_)),
261
+ retractall(kb_generation(_)),
262
+ retractall(kb_legacy_sentinel_stamp(_)),
263
+ retractall(kb_audit_sequence(_)),
264
+ retractall(kb_dirty),
265
+ retractall(kb_index_ready),
266
+ retractall(kb_index_dirty),
267
+ retractall(kb_index_entity(_, _)),
268
+ retractall(kb_index_type(_, _)),
269
+ retractall(kb_index_tag(_, _)),
270
+ retractall(kb_index_source(_, _)),
271
+ retractall(kb_index_token(_, _)),
272
+ retractall(kb_index_symbol_coordinate(_, _, _, _, _)),
273
+ retractall(kb_index_entity_count(_)),
274
+ retractall(kb_index_triple_count(_))
147
275
  )
148
276
  ; true
149
277
  ).
@@ -152,7 +280,9 @@ kb_detach :-
152
280
  % Save RDF graph and sync audit log to disk
153
281
  % implements REQ-009
154
282
  kb_save :-
155
- ( kb_attached(Directory)
283
+ ( kb_storage_mode(journaled)
284
+ -> kb_save_journaled
285
+ ; kb_attached(Directory)
156
286
  -> with_kb_mutex(with_kb_file_lock(Directory, kb_save_locked(Directory)))
157
287
  ; true
158
288
  ).
@@ -166,17 +296,29 @@ with_kb_file_lock(Directory, Goal) :-
166
296
  ).
167
297
 
168
298
  kb_save_locked(Directory) :-
299
+ ( kb_storage_mode(journaled)
300
+ -> kb_save_journaled_locked(Directory)
301
+ ; kb_save_legacy_locked(Directory)
302
+ ).
303
+
304
+ kb_save_legacy_locked(Directory) :-
169
305
  atom_concat(Directory, '/kb.rdf', DataFile),
170
306
  temp_rdf_file(Directory, TempFile),
171
307
  catch(
172
308
  (
173
309
  ensure_snapshot_current(DataFile),
310
+ kb_stage(snapshot_save),
174
311
  save_rdf_snapshot(TempFile),
312
+ kb_stage(audit_sync),
313
+ sync_audit_log,
314
+ % Keep the durable RDF rename after audit synchronization. The
315
+ % temporary snapshot can be discarded if an audit-stage failure
316
+ % occurs, so a failed commit cannot publish new RDF state.
317
+ kb_stage(snapshot_save),
175
318
  rename_file(TempFile, DataFile),
176
319
  current_data_stamp(DataFile, UpdatedStamp),
177
320
  retractall(kb_attached_snapshot(_)),
178
- assert(kb_attached_snapshot(UpdatedStamp)),
179
- sync_audit_log
321
+ assert(kb_attached_snapshot(UpdatedStamp))
180
322
  ),
181
323
  Error,
182
324
  (
@@ -185,6 +327,486 @@ kb_save_locked(Directory) :-
185
327
  )
186
328
  ).
187
329
 
330
+ % Journaled stores never rewrite kb.rdf on the hot path. SWI's monitor
331
+ % appends RDF edits to a per-source journal; flushing with min_size(16384)
332
+ % makes a compact binary snapshot only after a journal reaches 16 MiB.
333
+ kb_save_journaled :-
334
+ ( kb_attached(Directory)
335
+ -> with_kb_mutex(kb_save_journaled_locked(Directory))
336
+ ; true
337
+ ).
338
+
339
+ %% kb_sync_checkpoint
340
+ % Publish compiler freshness even when a successful sync has no RDF delta.
341
+ % The commit sequence remains the durable ordering boundary used by status;
342
+ % shutdown still uses kb_save/0 and therefore does not mint phantom commits.
343
+ kb_sync_checkpoint :-
344
+ kb_mark_dirty,
345
+ kb_save_journaled.
346
+
347
+ % implements REQ-core-journaled-engine-persistence
348
+ kb_save_journaled_locked(Directory) :-
349
+ ensure_journal_sentinel_current(Directory),
350
+ ( kb_dirty
351
+ -> kb_stage(journal_flush),
352
+ rdf_flush_journals([min_size(16384)]),
353
+ next_journal_commit(Generation, Sequence),
354
+ write_journal_head(Directory, Generation, Sequence),
355
+ retractall(kb_attached_snapshot(_)),
356
+ assert(kb_attached_snapshot(journal_snapshot(Generation, Sequence))),
357
+ retractall(kb_dirty)
358
+ ; true
359
+ ).
360
+
361
+ kb_mark_dirty :-
362
+ (kb_dirty -> true ; assertz(kb_dirty)).
363
+
364
+ kb_mark_index_dirty :-
365
+ (kb_index_dirty -> true ; assertz(kb_index_dirty)).
366
+
367
+ drop_entity_index_rows(Id) :-
368
+ retractall(kb_index_entity(Id, _)),
369
+ retractall(kb_index_type(_, Id)),
370
+ retractall(kb_index_tag(_, Id)),
371
+ retractall(kb_index_source(_, Id)),
372
+ retractall(kb_index_token(_, Id)),
373
+ retractall(kb_index_symbol_coordinate(Id, _, _, _, _)).
374
+
375
+ kb_refresh_entity_index(Id) :-
376
+ ( kb_index_ready,
377
+ \+ kb_index_dirty,
378
+ kb_index_entity_count(Count)
379
+ -> (kb_index_entity(Id, _) -> WasIndexed = true ; WasIndexed = false),
380
+ drop_entity_index_rows(Id),
381
+ ( once(kb_entity_raw(Id, Type, Props))
382
+ -> index_entity_row(Id, Type, Props),
383
+ (WasIndexed == true -> NextCount = Count ; NextCount is Count + 1)
384
+ ; (WasIndexed == true -> NextCount is max(0, Count - 1) ; NextCount = Count)
385
+ ),
386
+ retractall(kb_index_entity_count(_)),
387
+ assertz(kb_index_entity_count(NextCount))
388
+ ; kb_mark_index_dirty
389
+ ).
390
+
391
+ kb_remove_entity_index(Id) :-
392
+ ( kb_index_ready,
393
+ \+ kb_index_dirty,
394
+ kb_index_entity_count(Count)
395
+ -> (kb_index_entity(Id, _) -> WasIndexed = true ; WasIndexed = false),
396
+ drop_entity_index_rows(Id),
397
+ (WasIndexed == true -> NextCount is max(0, Count - 1) ; NextCount = Count),
398
+ retractall(kb_index_entity_count(_)),
399
+ assertz(kb_index_entity_count(NextCount))
400
+ ; kb_mark_index_dirty
401
+ ).
402
+
403
+ ensure_journal_sentinel_current(Directory) :-
404
+ atom_concat(Directory, '/kb.rdf', SentinelFile),
405
+ current_data_stamp(SentinelFile, CurrentStamp),
406
+ ( kb_legacy_sentinel_stamp(ExpectedStamp)
407
+ -> ( CurrentStamp == ExpectedStamp
408
+ -> true
409
+ ; throw(error(permission_error(save, kb, stale_snapshot), kb_save/0))
410
+ )
411
+ ; true
412
+ ).
413
+
414
+ next_journal_commit(Generation, Sequence) :-
415
+ ( kb_generation(CurrentGeneration)
416
+ -> Generation = CurrentGeneration
417
+ ; Generation = 'generation-1'
418
+ ),
419
+ ( kb_commit_sequence(CurrentSequence)
420
+ -> Sequence is CurrentSequence + 1
421
+ ; Sequence = 1
422
+ ),
423
+ retractall(kb_generation(_)),
424
+ assert(kb_generation(Generation)),
425
+ retractall(kb_commit_sequence(_)),
426
+ assert(kb_commit_sequence(Sequence)).
427
+
428
+ read_journal_metadata(Directory, Generation, Sequence) :-
429
+ atom_concat(Directory, '/CURRENT', HeadFile),
430
+ ( exists_file(HeadFile),
431
+ catch(read_file_to_string(HeadFile, Contents, []), _, fail),
432
+ split_string(Contents, ':\n\r', ' \t', Parts),
433
+ Parts = [GenerationString, SequenceString|_],
434
+ atom_string(Generation, GenerationString),
435
+ catch(number_string(Sequence, SequenceString), _, fail)
436
+ -> true
437
+ ; Generation = 'generation-1',
438
+ Sequence = 0
439
+ ).
440
+
441
+ write_journal_head(Directory, Generation, Sequence) :-
442
+ atom_concat(Directory, '/CURRENT.tmp', TempFile),
443
+ atom_concat(Directory, '/CURRENT', HeadFile),
444
+ setup_call_cleanup(
445
+ open(TempFile, write, Stream, [encoding(utf8)]),
446
+ format(Stream, '~w:~w~n', [Generation, Sequence]),
447
+ close(Stream)
448
+ ),
449
+ rename_file(TempFile, HeadFile).
450
+
451
+ %% kb_storage_status(-Status)
452
+ % Return a compact operational view without scanning the RDF graph.
453
+ kb_storage_status(Status) :-
454
+ kb_storage_mode(Mode),
455
+ kb_attached(Directory),
456
+ (kb_generation(Generation) -> true ; Generation = unknown),
457
+ (kb_commit_sequence(Sequence) -> true ; Sequence = 0),
458
+ journal_bytes(Directory, JournalBytes),
459
+ Status = _{
460
+ mode: Mode,
461
+ directory: Directory,
462
+ generation: Generation,
463
+ sequence: Sequence,
464
+ journalBytes: JournalBytes
465
+ }.
466
+
467
+ journal_bytes(Directory, Bytes) :-
468
+ ( kb_persistency_directory(_PersistencyDirectory)
469
+ -> ( findall(Size,
470
+ ( rdf_journal_file(_Graph, JournalFile),
471
+ size_file(JournalFile, Size) ),
472
+ Sizes),
473
+ sum_list(Sizes, Bytes)
474
+ )
475
+ ; atom_concat(Directory, '/audit.log', AuditLog),
476
+ (exists_file(AuditLog) -> size_file(AuditLog, Bytes) ; Bytes = 0)
477
+ ).
478
+
479
+ directory_size(Directory, Bytes) :-
480
+ ( exists_directory(Directory)
481
+ -> directory_files(Directory, Files),
482
+ directory_size_files(Directory, Files, 0, Bytes)
483
+ ; Bytes = 0
484
+ ).
485
+
486
+ directory_size_files(_, [], Total, Total).
487
+ directory_size_files(Directory, [File|Rest], Acc, Total) :-
488
+ ( memberchk(File, ['.', '..'])
489
+ -> Next = Acc
490
+ ; atom_concat(Directory, '/', Prefix),
491
+ atom_concat(Prefix, File, FullPath),
492
+ ( exists_directory(FullPath)
493
+ -> directory_size(FullPath, ChildSize), Next is Acc + ChildSize
494
+ ; exists_file(FullPath), size_file(FullPath, Size)
495
+ -> Next is Acc + Size
496
+ ; Next = Acc
497
+ )
498
+ ),
499
+ directory_size_files(Directory, Rest, Next, Total).
500
+
501
+ %% kb_storage_compact
502
+ kb_storage_compact :-
503
+ kb_storage_mode(journaled),
504
+ kb_attached(Directory),
505
+ with_kb_mutex(kb_storage_compact_locked(Directory)).
506
+
507
+ %% kb_storage_compact_if_needed
508
+ % Compact only when the live journal has crossed the 16 MiB idle-compaction
509
+ % threshold. The engine invokes this predicate after the client queue drains;
510
+ % ordinary writes only append durable journal records and never rewrite a
511
+ % snapshot on the hot path.
512
+ kb_storage_compact_if_needed :-
513
+ kb_storage_mode(journaled),
514
+ kb_attached(Directory),
515
+ with_kb_mutex((
516
+ journal_bytes(Directory, JournalBytes),
517
+ ( JournalBytes >= 16777216
518
+ -> kb_storage_compact_locked(Directory)
519
+ ; true
520
+ )
521
+ )).
522
+
523
+ kb_storage_compact_locked(Directory) :-
524
+ % A caller may explicitly compact immediately after a low-level mutation
525
+ % that has not gone through kb_save/0. Publish that dirty transaction
526
+ % first so CURRENT never points at an older commit sequence.
527
+ kb_save_journaled_locked(Directory),
528
+ rdf_flush_journals([min_size(0)]),
529
+ ( kb_generation(Generation) -> true ; Generation = 'generation-1' ),
530
+ ( kb_commit_sequence(Sequence) -> true ; Sequence = 0 ),
531
+ write_journal_head(Directory, Generation, Sequence).
532
+
533
+ %% kb_storage_export(+TargetDirectory)
534
+ % Export a journaled branch to the legacy RDF/XML shape without changing the
535
+ % active store. The human-readable audit export is intentionally derived.
536
+ kb_storage_export(TargetDirectory) :-
537
+ kb_graph(GraphURI),
538
+ ( exists_directory(TargetDirectory)
539
+ -> true
540
+ ; make_directory_path(TargetDirectory)
541
+ ),
542
+ atom_concat(TargetDirectory, '/kb.rdf', TargetFile),
543
+ rdf_save(TargetFile,
544
+ [graph(GraphURI), base_uri('urn-kibi:'), namespaces([kb, xsd])]),
545
+ atom_concat(TargetDirectory, '/audit.log', AuditFile),
546
+ setup_call_cleanup(
547
+ open(AuditFile, write, Stream, [encoding(utf8)]),
548
+ export_audit_events(Stream, GraphURI),
549
+ close(Stream)
550
+ ).
551
+
552
+ %% kb_migrate_legacy(+SourceDirectory, +TargetDirectory)
553
+ % Copy a legacy RDF/audit graph into a fresh journaled directory. The caller
554
+ % publishes the staged directory atomically; this predicate deliberately
555
+ % leaves the source untouched on failure.
556
+ % implements REQ-core-journaled-engine-persistence
557
+ kb_migrate_legacy(SourceDirectory, TargetDirectory) :-
558
+ kb_attached(SourceDirectory),
559
+ kb_storage_mode(legacy),
560
+ % Hold the legacy branch lock for the complete import and audit copy so a
561
+ % pre-cutover writer cannot change the source between validation and
562
+ % publication.
563
+ with_kb_file_lock_nowait(
564
+ SourceDirectory,
565
+ kb_migrate_legacy_locked(SourceDirectory, TargetDirectory)
566
+ ).
567
+
568
+ % Migration is a cutover operation, not a background writer. Never leave a
569
+ % CLI/engine request blocked indefinitely behind an old MCP process that still
570
+ % owns the legacy lock; surface the lock owner to the caller so it can stop the
571
+ % old client and retry. Ordinary legacy saves retain their blocking lock
572
+ % semantics for compatibility.
573
+ with_kb_file_lock_nowait(Directory, Goal) :-
574
+ atom_concat(Directory, '/kb.lock', LockFile),
575
+ setup_call_cleanup(
576
+ open(LockFile, append, LockStream, [lock(write), wait(false)]),
577
+ call(Goal),
578
+ close(LockStream)
579
+ ).
580
+
581
+ kb_migrate_legacy_locked(_SourceDirectory, TargetDirectory) :-
582
+ kb_graph(SourceGraph),
583
+ findall(
584
+ t(S, P, O),
585
+ ( rdf(S, P, O, SourceGraph),
586
+ \+ audit_resource(S)
587
+ ),
588
+ Triples
589
+ ),
590
+ findall(c(TS, Op, Entity, Data),
591
+ changeset(TS, Op, Entity, Data),
592
+ Audits),
593
+ canonical_triple_digest(Triples, SourceDigest),
594
+ length(Triples, TripleCount),
595
+ length(Audits, AuditCount),
596
+ kb_detach,
597
+ ( exists_directory(TargetDirectory)
598
+ -> true
599
+ ; make_directory_path(TargetDirectory)
600
+ ),
601
+ write_journal_marker(TargetDirectory),
602
+ kb_attach(TargetDirectory),
603
+ kb_graph(TargetGraph),
604
+ forall(member(t(S, P, O), Triples), rdf_assert(S, P, O, TargetGraph)),
605
+ assert_migration_audits(Audits, 1),
606
+ kb_mark_dirty,
607
+ kb_mark_index_dirty,
608
+ validate_migrated_graph(
609
+ TargetGraph,
610
+ SourceDigest,
611
+ TripleCount,
612
+ AuditCount
613
+ ),
614
+ kb_save_journaled,
615
+ kb_detach.
616
+
617
+ canonical_triple_digest(Triples, Digest) :-
618
+ sort(Triples, Canonical),
619
+ term_hash(Canonical, Digest).
620
+
621
+ validate_migrated_graph(Graph, ExpectedDigest, ExpectedTriples, ExpectedAudits) :-
622
+ findall(
623
+ t(S, P, O),
624
+ ( rdf(S, P, O, Graph),
625
+ \+ audit_resource(S)
626
+ ),
627
+ Triples
628
+ ),
629
+ length(Triples, ActualTriples),
630
+ ( ActualTriples =:= ExpectedTriples
631
+ -> true
632
+ ; throw(error(migration_validation(triple_count, ExpectedTriples, ActualTriples),
633
+ kb_migrate_legacy/2))
634
+ ),
635
+ canonical_triple_digest(Triples, ActualDigest),
636
+ ( ActualDigest == ExpectedDigest
637
+ -> true
638
+ ; throw(error(migration_validation(triple_digest, ExpectedDigest, ActualDigest),
639
+ kb_migrate_legacy/2))
640
+ ),
641
+ findall(Audit,
642
+ rdf(Audit, 'urn:kibi:audit/operation', _, Graph),
643
+ AuditResources),
644
+ length(AuditResources, ActualAudits),
645
+ ( ActualAudits =:= ExpectedAudits
646
+ -> true
647
+ ; throw(error(migration_validation(audit_count, ExpectedAudits, ActualAudits),
648
+ kb_migrate_legacy/2))
649
+ ),
650
+ % Re-run the schema and relationship invariants before publication. This
651
+ % catches missing required fields and dangling edges while the staged
652
+ % generation is still disposable.
653
+ findall(
654
+ problem(Id, Type),
655
+ ( kb_entity(Id, Type, Props),
656
+ normalize_migration_props(Type, Props, ValidationProps),
657
+ \+ catch(validate_entity(Type, ValidationProps), _, fail)
658
+ ),
659
+ EntityProblems
660
+ ),
661
+ ( EntityProblems == []
662
+ -> true
663
+ ; throw(error(migration_validation(entity_schema, EntityProblems),
664
+ kb_migrate_legacy/2))
665
+ ),
666
+ findall(
667
+ problem(RelType, FromId, ToId),
668
+ ( relationship_type(RelType),
669
+ kb_relationship(RelType, FromId, ToId),
670
+ ( \+ once(kb_entity(FromId, _, _))
671
+ ; \+ once(kb_entity(ToId, _, _))
672
+ ; once(kb_entity(FromId, FromType, _)),
673
+ once(kb_entity(ToId, ToType, _)),
674
+ \+ catch(validate_relationship(RelType, FromType, ToType), _, fail)
675
+ )
676
+ ),
677
+ RelationshipProblems
678
+ ),
679
+ ( RelationshipProblems == []
680
+ -> true
681
+ ; throw(error(migration_validation(relationships, RelationshipProblems),
682
+ kb_migrate_legacy/2))
683
+ ).
684
+
685
+ assert_migration_audits([], _).
686
+ assert_migration_audits([c(TS, Op, Entity, Data)|Rest], Index) :-
687
+ kb_assert_audit_event_with_timestamp(TS, Op, Entity, Data, Index),
688
+ NextIndex is Index + 1,
689
+ assert_migration_audits(Rest, NextIndex).
690
+
691
+ normalize_migration_props(_, [], []).
692
+ normalize_migration_props(Type, [Key=Value|Rest], NormalizedProps) :-
693
+ normalize_migration_props(Type, Rest, RestProps),
694
+ ( entity_property(Type, Key, _)
695
+ -> normalize_migration_value(Type, Key, Value, Normalized),
696
+ NormalizedProps = [Key=Normalized|RestProps]
697
+ ; % Preserve unknown legacy RDF fields in the copied graph, but do not
698
+ % reject an otherwise valid entity merely because an older schema
699
+ % version stored an extension that the current validator no longer
700
+ % models. Required/currently typed fields are still validated below.
701
+ NormalizedProps = RestProps
702
+ ).
703
+
704
+ % Legacy RDF reads intentionally preserve typed literal wrappers for the
705
+ % public query API. Validation, however, needs the schema-level value (atom,
706
+ % string, number, boolean, or list) that an upsert would have received. Use
707
+ % the entity schema as the decoder instead of maintaining another list of
708
+ % fields that can drift as the schema evolves.
709
+ normalize_migration_value(Type, Key, Raw, Normalized) :-
710
+ migration_unwrap_literal(Raw, Value),
711
+ ( entity_property(Type, Key, Kind)
712
+ -> normalize_migration_kind(Kind, Value, Normalized)
713
+ ; Normalized = Value
714
+ ).
715
+
716
+ migration_unwrap_literal(^^(Value, _Type), Value) :- !.
717
+ migration_unwrap_literal(literal(type(_, Value)), Value) :- !.
718
+ migration_unwrap_literal(literal(lang(_, Value)), Value) :- !.
719
+ migration_unwrap_literal(literal(Value), Value) :- !.
720
+ migration_unwrap_literal(Value, Value).
721
+
722
+ normalize_migration_kind(atom, Value, Normalized) :-
723
+ !,
724
+ normalize_term_atom(Value, Normalized).
725
+ normalize_migration_kind(atom_or_string, Value, Normalized) :-
726
+ !,
727
+ (atom(Value) ; string(Value)),
728
+ Normalized = Value.
729
+ normalize_migration_kind(string, Value, Normalized) :-
730
+ !,
731
+ migration_string(Value, Normalized).
732
+ normalize_migration_kind(uri, Value, Normalized) :-
733
+ !,
734
+ migration_string(Value, Normalized).
735
+ normalize_migration_kind(datetime, Value, Normalized) :-
736
+ !,
737
+ migration_string(Value, Normalized).
738
+ normalize_migration_kind(list, Value, Normalized) :-
739
+ !,
740
+ ( is_list(Value)
741
+ -> maplist(normalize_migration_list_value, Value, Normalized)
742
+ ; Normalized = Value
743
+ ).
744
+ normalize_migration_kind(list_or_json, Value, Value) :- !.
745
+ normalize_migration_kind(integer, Value, Normalized) :-
746
+ !,
747
+ (integer(Value) -> Normalized = Value ; atom_number(Value, Normalized)).
748
+ normalize_migration_kind(number, Value, Normalized) :-
749
+ !,
750
+ (number(Value) -> Normalized = Value ; atom_number(Value, Normalized)).
751
+ normalize_migration_kind(boolean, Value, Value) :- !.
752
+ normalize_migration_kind(_, Value, Value).
753
+
754
+ migration_string(Value, Value) :- string(Value), !.
755
+ migration_string(Value, String) :- atom(Value), !, atom_string(Value, String).
756
+ migration_string(Value, String) :- number(Value), !, number_string(Value, String).
757
+ migration_string(Value, String) :- term_string(Value, String).
758
+
759
+ normalize_migration_list_value(Value, Normalized) :-
760
+ (atom(Value) -> Normalized = Value ; string(Value) -> Normalized = Value ; Normalized = Value).
761
+
762
+ migration_atom_key(id).
763
+ migration_atom_key(status).
764
+ migration_atom_key(owner).
765
+ migration_atom_key(priority).
766
+ migration_atom_key(severity).
767
+ migration_atom_key(symbol_role).
768
+ migration_atom_key(granularity_reason).
769
+ migration_atom_key(fact_kind).
770
+ migration_atom_key(operator).
771
+ migration_atom_key(value_type).
772
+ migration_atom_key(polarity).
773
+
774
+ audit_resource(Resource) :-
775
+ atom(Resource),
776
+ sub_atom(Resource, 0, _, _, 'urn:kibi:audit/').
777
+
778
+ write_journal_marker(Directory) :-
779
+ atom_concat(Directory, '/storage.json', Marker),
780
+ setup_call_cleanup(
781
+ open(Marker, write, Stream, [encoding(utf8)]),
782
+ format(Stream,
783
+ '{"format":"kibi.rdf-journal.v1","schemaVersion":1}~n',
784
+ []),
785
+ close(Stream)
786
+ ),
787
+ get_time(Now),
788
+ Millis is floor(Now * 1000),
789
+ (current_prolog_flag(pid, Pid) -> true ; Pid = 0),
790
+ format(atom(Generation), 'generation-~w-~w', [Pid, Millis]),
791
+ atom_concat(Directory, '/CURRENT', HeadFile),
792
+ setup_call_cleanup(
793
+ open(HeadFile, write, HeadStream, [encoding(utf8)]),
794
+ format(HeadStream, '~w:0~n', [Generation]),
795
+ close(HeadStream)
796
+ ).
797
+
798
+ export_audit_events(Stream, GraphURI) :-
799
+ forall(
800
+ rdf(Audit, 'urn:kibi:audit/operation', Operation, GraphURI),
801
+ (
802
+ rdf(Audit, 'urn:kibi:audit/timestamp', Timestamp, GraphURI),
803
+ rdf(Audit, 'urn:kibi:audit/entity', Entity, GraphURI),
804
+ rdf(Audit, 'urn:kibi:audit/data', Data, GraphURI),
805
+ format(Stream, 'changeset(~q, ~q, ~q, ~q).~n',
806
+ [Timestamp, Operation, Entity, Data])
807
+ )
808
+ ).
809
+
188
810
  temp_rdf_file(Directory, TempFile) :-
189
811
  get_time(Timestamp),
190
812
  Millis is floor(Timestamp * 1000),
@@ -234,6 +856,22 @@ ensure_snapshot_current(DataFile) :-
234
856
  with_kb_mutex(Goal) :-
235
857
  with_mutex(kb_lock, Goal).
236
858
 
859
+ % Run a journaled RDF mutation atomically and only mark the branch dirty after
860
+ % the transaction commits. A failed validation/contradiction check therefore
861
+ % cannot make a later shutdown publish a phantom commit sequence. Preserve a
862
+ % pre-existing dirty bit for callers that intentionally batch several writes
863
+ % before an explicit kb_save/0.
864
+ kb_run_journaled_mutation(Goal) :-
865
+ ( kb_dirty -> WasDirty = true ; WasDirty = false ),
866
+ catch(
867
+ rdf_transaction(Goal),
868
+ Error,
869
+ ( (WasDirty == false -> retractall(kb_dirty) ; true),
870
+ throw(Error)
871
+ )
872
+ ),
873
+ kb_mark_dirty.
874
+
237
875
  %% load_kb_pl_files(+Directory)
238
876
  % Load legacy .pl entity files from the KB directory.
239
877
  % These files use entity/4 format: entity(Type, Id, Title, Props).
@@ -264,8 +902,15 @@ kb_assert_entity(Type, Props) :-
264
902
  -> ChangeKind = updated
265
903
  ; ChangeKind = created
266
904
  ),
267
- kb_assert_entity_no_audit(Type, Props),
268
- kb_log_entity_upsert(ChangeKind, Type, Props).
905
+ ( kb_storage_mode(journaled)
906
+ -> kb_run_journaled_mutation((
907
+ kb_assert_entity_no_audit(Type, Props),
908
+ kb_log_entity_upsert(ChangeKind, Type, Props)
909
+ ))
910
+ ; kb_mark_dirty,
911
+ kb_assert_entity_no_audit(Type, Props),
912
+ kb_log_entity_upsert(ChangeKind, Type, Props)
913
+ ).
269
914
 
270
915
  %% kb_assert_entity_no_audit(+Type, +Properties)
271
916
  % Assert an entity RDF payload without recording audit side effects.
@@ -302,26 +947,229 @@ kb_assert_entity_no_audit(Type, Props) :-
302
947
  member(Key=Value, Props),
303
948
  store_property(EntityURI, Key, Value, Graph)
304
949
  )
950
+ )),
951
+ kb_refresh_entity_index(Id).
952
+
953
+ %% kb_commit_upsert(+Type, +Properties, +Relationships, +SkipContradiction, -ChangeKind)
954
+ % Commit an entity upsert, its relationships, audit entries, and RDF snapshot
955
+ % as one bounded operation. The branch lock is held before any RDF mutation,
956
+ % so concurrent runtimes cannot observe or publish an intermediate upsert.
957
+ % Relationships use rel(Type, FromId, ToId, Metadata) terms. Metadata is
958
+ % retained for the caller's serialization contract; the audit schema records
959
+ % only the relationship endpoints and type, as before.
960
+ kb_commit_upsert(Type, Props, Relationships, SkipContradiction, ChangeKind) :-
961
+ kb_storage_mode(journaled),
962
+ !,
963
+ memberchk(id=Id, Props),
964
+ memberchk(SkipContradiction, [true, false]),
965
+ upsert_change_kind(Id, ChangeKind),
966
+ with_kb_mutex((
967
+ ( kb_dirty -> WasDirty = true ; WasDirty = false ),
968
+ catch(
969
+ rdf_transaction((
970
+ kb_stage(rdf_mutation),
971
+ kb_assert_entity_no_audit(Type, Props),
972
+ kb_commit_relationships_no_audit(Relationships),
973
+ kb_maybe_check_req_contradiction(Type, Id, SkipContradiction),
974
+ kb_stage(entity_audit),
975
+ kb_log_entity_upsert(ChangeKind, Type, Props),
976
+ kb_stage(relationship_audit),
977
+ kb_commit_relationship_audits(Relationships)
978
+ )),
979
+ Error,
980
+ ( (WasDirty == false -> retractall(kb_dirty) ; true),
981
+ throw(Error)
982
+ )
983
+ ),
984
+ kb_mark_dirty,
985
+ kb_save_journaled
986
+ )).
987
+ kb_commit_upsert(Type, Props, Relationships, SkipContradiction, ChangeKind) :-
988
+ memberchk(id=Id, Props),
989
+ memberchk(SkipContradiction, [true, false]),
990
+ kb_attached(Directory),
991
+ atom_concat(Directory, '/kb.rdf', DataFile),
992
+ kb_stage(runtime),
993
+ kb_runtime_diagnostic,
994
+ % Emit the lock marker before waiting so a timeout identifies a branch
995
+ % lock held by another runtime instead of reporting only the prior stage.
996
+ kb_stage(lock),
997
+ with_kb_file_lock(Directory, (
998
+ audit_store_writable,
999
+ ensure_snapshot_current(DataFile),
1000
+ upsert_change_kind(Id, ChangeKind),
1001
+ rdf_transaction((
1002
+ kb_stage(rdf_mutation),
1003
+ kb_assert_entity_no_audit(Type, Props),
1004
+ kb_commit_relationships_no_audit(Relationships),
1005
+ kb_maybe_check_req_contradiction(Type, Id, SkipContradiction),
1006
+ kb_stage(entity_audit),
1007
+ kb_log_entity_upsert(ChangeKind, Type, Props),
1008
+ kb_stage(relationship_audit),
1009
+ kb_commit_relationship_audits(Relationships),
1010
+ kb_save_locked(Directory)
1011
+ ))
305
1012
  )).
306
1013
 
1014
+ upsert_change_kind(Id, updated) :-
1015
+ once(kb_entity(Id, _, _)),
1016
+ !.
1017
+ upsert_change_kind(_, created).
1018
+
1019
+ kb_commit_relationships_no_audit([]).
1020
+ kb_commit_relationships_no_audit([
1021
+ rel(RelType, FromId, ToId, Metadata)|Rest
1022
+ ]) :-
1023
+ kb_assert_relationship_no_audit(RelType, FromId, ToId, Metadata),
1024
+ kb_commit_relationships_no_audit(Rest).
1025
+
1026
+ kb_maybe_check_req_contradiction(req, Id, false) :-
1027
+ !,
1028
+ kb_stage(contradiction_check),
1029
+ check_req_contradiction(Id).
1030
+ kb_maybe_check_req_contradiction(_, _, _).
1031
+
1032
+ kb_commit_relationship_audits([]).
1033
+ kb_commit_relationship_audits([
1034
+ rel(RelType, FromId, ToId, _Metadata)|Rest
1035
+ ]) :-
1036
+ kb_log_relationship_upsert(RelType, FromId, ToId, []),
1037
+ kb_commit_relationship_audits(Rest).
1038
+
1039
+ % Probe the existing journal without waiting. A process running code from
1040
+ % before the sync(close) fix may still retain the journal's write lock; fail
1041
+ % before touching RDF so callers receive a safe, retryable diagnostic.
1042
+ audit_store_writable :-
1043
+ kb_audit_db(AuditLog),
1044
+ ( exists_file(AuditLog)
1045
+ -> catch(
1046
+ setup_call_cleanup(
1047
+ open(AuditLog, append, Stream,
1048
+ [lock(write), wait(false), encoding(utf8)]),
1049
+ true,
1050
+ close(Stream)
1051
+ ),
1052
+ Error,
1053
+ throw(error(permission_error(lock, audit_log, AuditLog),
1054
+ context(kb_commit_upsert/5, Error)))
1055
+ )
1056
+ ; true
1057
+ ).
1058
+
1059
+ % Reserved stderr markers are consumed by PrologProcess diagnostics and never
1060
+ % form part of the JSON result. They make timeout errors stage-specific even
1061
+ % when the child is blocked inside a filesystem primitive.
1062
+ kb_stage(Stage) :-
1063
+ format(user_error, '__KIBI_STAGE__:~w~n', [Stage]),
1064
+ flush_output(user_error).
1065
+
1066
+ kb_runtime_diagnostic :-
1067
+ ( current_prolog_flag(pid, Pid) -> true ; Pid = unknown ),
1068
+ ( kb_audit_db(AuditLog) -> true ; AuditLog = unknown ),
1069
+ kb_environment_value('KIBI_RUNTIME_NAME', RuntimeName),
1070
+ kb_environment_value('KIBI_RUNTIME_VERSION', RuntimeVersion),
1071
+ kb_environment_value('KIBI_PACKAGE_VERSIONS', PackageVersions),
1072
+ format(user_error,
1073
+ '__KIBI_RUNTIME__:pid=~w;audit=~w;runtime=~w@~w;packages=~w~n',
1074
+ [Pid, AuditLog, RuntimeName, RuntimeVersion, PackageVersions]),
1075
+ flush_output(user_error).
1076
+
1077
+ kb_environment_value(Name, Value) :-
1078
+ ( getenv(Name, Value)
1079
+ -> true
1080
+ ; Value = unknown
1081
+ ).
1082
+
307
1083
  %% kb_retract_entity_relationships(+Id)
308
1084
  % Remove all relationship triples for an entity, preserving property triples.
309
1085
  % Used by projectStagedEntities to clear stale relationships before re-asserting.
310
1086
  kb_retract_entity_relationships(Id) :-
311
1087
  kb_graph(Graph),
312
- with_kb_mutex((
313
- entity_id_to_uri(Id, EntityURI),
314
- forall(
315
- ( rdf(EntityURI, RelURI, TargetURI, Graph),
316
- atom(TargetURI),
317
- entity_uri_to_id(TargetURI, _)
318
- ),
319
- rdf_retractall(EntityURI, RelURI, TargetURI, Graph)
1088
+ ( kb_storage_mode(journaled)
1089
+ -> kb_run_journaled_mutation(with_kb_mutex((
1090
+ entity_id_to_uri(Id, EntityURI),
1091
+ forall(
1092
+ ( rdf(EntityURI, RelURI, TargetURI, Graph),
1093
+ atom(TargetURI),
1094
+ entity_uri_to_id(TargetURI, _)
1095
+ ),
1096
+ rdf_retractall(EntityURI, RelURI, TargetURI, Graph)
1097
+ )
1098
+ )))
1099
+ ; kb_mark_dirty,
1100
+ with_kb_mutex((
1101
+ entity_id_to_uri(Id, EntityURI),
1102
+ forall(
1103
+ ( rdf(EntityURI, RelURI, TargetURI, Graph),
1104
+ atom(TargetURI),
1105
+ entity_uri_to_id(TargetURI, _)
1106
+ ),
1107
+ rdf_retractall(EntityURI, RelURI, TargetURI, Graph)
1108
+ )
1109
+ ))
1110
+ ).
1111
+
1112
+ %% kb_retract_relationship(+Type, +From, +To)
1113
+ % Retract one compiled edge and its audit record atomically. This is used by
1114
+ % relationship-shard deltas so a small shard edit never requires clearing and
1115
+ % rebuilding every edge in the graph.
1116
+ kb_retract_relationship(RelType, FromId, ToId) :-
1117
+ kb_graph(Graph),
1118
+ entity_id_to_uri(FromId, FromURI),
1119
+ entity_id_to_uri(ToId, ToURI),
1120
+ kb_uri(BaseURI),
1121
+ atom_concat(BaseURI, RelType, RelURI),
1122
+ ( kb_storage_mode(journaled)
1123
+ -> kb_run_journaled_mutation(with_kb_mutex((
1124
+ rdf_retractall(FromURI, RelURI, ToURI, Graph),
1125
+ format(atom(RelId), '~w->~w', [FromId, ToId]),
1126
+ kb_assert_audit_event(delete_rel, RelId,
1127
+ RelType-[from=FromId, to=ToId])
1128
+ )))
1129
+ ; kb_mark_dirty,
1130
+ with_kb_mutex(rdf_retractall(FromURI, RelURI, ToURI, Graph))
1131
+ ).
1132
+
1133
+ %% kb_retract_all_relationships/0
1134
+ % Remove only entity-to-entity edges. This is used when a relationship shard
1135
+ % changes: RDF property literals and audit resources remain untouched, while
1136
+ % the current shard contents can be asserted again in one transaction.
1137
+ % implements REQ-core-journaled-engine-persistence
1138
+ kb_retract_all_relationships :-
1139
+ kb_graph(Graph),
1140
+ ( kb_storage_mode(journaled)
1141
+ -> kb_run_journaled_mutation(with_kb_mutex(
1142
+ forall(
1143
+ ( rdf(SubjectURI, RelURI, ObjectURI, Graph),
1144
+ atom(SubjectURI),
1145
+ atom(ObjectURI),
1146
+ entity_uri_to_id(SubjectURI, _),
1147
+ entity_uri_to_id(ObjectURI, _)
1148
+ ),
1149
+ rdf_retractall(SubjectURI, RelURI, ObjectURI, Graph)
1150
+ )
1151
+ ))
1152
+ ; kb_mark_dirty,
1153
+ with_kb_mutex(
1154
+ forall(
1155
+ ( rdf(SubjectURI, RelURI, ObjectURI, Graph),
1156
+ atom(SubjectURI),
1157
+ atom(ObjectURI),
1158
+ entity_uri_to_id(SubjectURI, _),
1159
+ entity_uri_to_id(ObjectURI, _)
1160
+ ),
1161
+ rdf_retractall(SubjectURI, RelURI, ObjectURI, Graph)
1162
+ )
320
1163
  )
321
- )).
1164
+ ).
322
1165
 
323
1166
  %% kb_log_entity_upsert(+ChangeKind, +Type, +Properties)
324
1167
  % Append the audit entry for a successfully committed entity upsert.
1168
+ kb_log_entity_upsert(ChangeKind, Type, Props) :-
1169
+ kb_storage_mode(journaled),
1170
+ !,
1171
+ memberchk(id=Id, Props),
1172
+ kb_assert_audit_event(upsert, Id, Type-[change_kind=ChangeKind|Props]).
325
1173
  kb_log_entity_upsert(ChangeKind, Type, Props) :-
326
1174
  memberchk(id=Id, Props),
327
1175
  memberchk(ChangeKind, [created, updated]),
@@ -343,6 +1191,26 @@ kb_retract_entity(Id) :-
343
1191
 
344
1192
  %% kb_retract_entity(+Id, +Type, +AuditProps)
345
1193
  % Remove an entity from the KB and log the provided delete payload.
1194
+ kb_retract_entity(Id, Type, AuditProps) :-
1195
+ kb_storage_mode(journaled),
1196
+ !,
1197
+ kb_graph(Graph),
1198
+ kb_run_journaled_mutation(with_kb_mutex((
1199
+ entity_id_to_uri(Id, EntityURI),
1200
+ % A source delta may replace an entity that is still referenced by
1201
+ % another source. Remove both directions so the transaction cannot
1202
+ % leave a dangling relationship behind. The compiler re-adds valid
1203
+ % edges from the current source shards in the same sync.
1204
+ forall(
1205
+ ( rdf(SubjectURI, RelURI, EntityURI, Graph),
1206
+ entity_uri_to_id(SubjectURI, _)
1207
+ ),
1208
+ rdf_retractall(SubjectURI, RelURI, EntityURI, Graph)
1209
+ ),
1210
+ rdf_retractall(EntityURI, _, _, Graph),
1211
+ kb_assert_audit_event(delete, Id, Type-AuditProps),
1212
+ kb_remove_entity_index(Id)
1213
+ ))).
346
1214
  kb_retract_entity(Id, Type, AuditProps) :-
347
1215
  kb_graph(Graph),
348
1216
  with_kb_mutex((
@@ -353,12 +1221,13 @@ kb_retract_entity(Id, Type, AuditProps) :-
353
1221
  % Log to audit
354
1222
  get_time(Timestamp),
355
1223
  format_time(atom(TS), '%FT%T%:z', Timestamp),
356
- assert_changeset(TS, delete, Id, Type-AuditProps)
1224
+ assert_changeset(TS, delete, Id, Type-AuditProps),
1225
+ kb_remove_entity_index(Id)
357
1226
  )).
358
1227
 
359
1228
  entity_delete_audit_props(Id, Props, AuditProps) :-
360
1229
  findall(Key=Value,
361
- ( member(Key, [title, source, text_ref]),
1230
+ ( member(Key, [title, source, text_ref, semantic_text]),
362
1231
  memberchk(Key=RawValue, Props),
363
1232
  audit_property_value(RawValue, Value)
364
1233
  ),
@@ -381,6 +1250,20 @@ audit_property_value(RawValue, Value) :-
381
1250
  % Query entities from the KB.
382
1251
  % Properties is unified with a list of Key=Value pairs.
383
1252
  kb_entity(Id, Type, Props) :-
1253
+ ( nonvar(Id)
1254
+ -> kb_entity_raw(Id, Type, Props)
1255
+ ; kb_ensure_indexes,
1256
+ ( nonvar(Type)
1257
+ -> kb_index_type(Type, IndexedId),
1258
+ kb_entity_raw(IndexedId, Type, Props),
1259
+ Id = IndexedId
1260
+ ; kb_entity_raw(Id, Type, Props)
1261
+ )
1262
+ ).
1263
+
1264
+ % Raw RDF/entity facts are kept separate from the index wrapper. Rebuilding
1265
+ % an index must never recurse through the indexed query path.
1266
+ kb_entity_raw(Id, Type, Props) :-
384
1267
  kb_graph(Graph),
385
1268
  % Find entity by pattern - use unquoted namespace term kb:type
386
1269
  ( var(Id)
@@ -398,14 +1281,115 @@ kb_entity(Id, Type, Props) :-
398
1281
  atom_concat(BaseURI, type, TypeURI),
399
1282
  PropURI \= TypeURI,
400
1283
  uri_to_key(PropURI, Key),
401
- literal_to_value(ValueLiteral, Value)
1284
+ literal_to_value(Key, ValueLiteral, Value)
402
1285
  ), Props).
403
1286
 
404
1287
  % Fallback: read from legacy entity/4 facts loaded from .pl files
405
- kb_entity(Id, Type, Props) :-
1288
+ kb_entity_raw(Id, Type, Props) :-
406
1289
  entity(Type, Id, _Title, PropList),
407
1290
  convert_legacy_props(PropList, Props).
408
1291
 
1292
+ kb_ensure_indexes :-
1293
+ ( kb_index_ready,
1294
+ \+ kb_index_dirty,
1295
+ kb_index_entity_count(Expected),
1296
+ current_entity_index_count(Expected)
1297
+ -> true
1298
+ ; kb_rebuild_indexes
1299
+ ).
1300
+
1301
+ % Rebuild all acceleration structures from RDF/entity facts. This is
1302
+ % intentionally disposable: no query result is written back to RDF when an
1303
+ % index is stale or externally invalidated.
1304
+ kb_rebuild_indexes :-
1305
+ kb_attached(_),
1306
+ with_kb_mutex((
1307
+ retractall(kb_index_entity(_, _)),
1308
+ retractall(kb_index_type(_, _)),
1309
+ retractall(kb_index_tag(_, _)),
1310
+ retractall(kb_index_source(_, _)),
1311
+ retractall(kb_index_token(_, _)),
1312
+ retractall(kb_index_symbol_coordinate(_, _, _, _, _)),
1313
+ findall(Id-Type-Props, kb_entity_raw(Id, Type, Props), Rows),
1314
+ forall(member(Id-Type-Props, Rows), index_entity_row(Id, Type, Props)),
1315
+ length(Rows, EntityCount),
1316
+ retractall(kb_index_entity_count(_)),
1317
+ assertz(kb_index_entity_count(EntityCount)),
1318
+ rdf_statistics(triples(TripleCount)),
1319
+ retractall(kb_index_triple_count(_)),
1320
+ assertz(kb_index_triple_count(TripleCount)),
1321
+ retractall(kb_index_dirty),
1322
+ retractall(kb_index_ready),
1323
+ assertz(kb_index_ready)
1324
+ )).
1325
+
1326
+ current_entity_index_count(Count) :-
1327
+ aggregate_all(count,
1328
+ rdf(_, kb:type, _, _),
1329
+ RdfCount),
1330
+ aggregate_all(count,
1331
+ entity(_, _, _, _),
1332
+ LegacyCount),
1333
+ Count is RdfCount + LegacyCount.
1334
+
1335
+ index_entity_row(Id, Type, Props) :-
1336
+ assertz(kb_index_entity(Id, Type)),
1337
+ assertz(kb_index_type(Type, Id)),
1338
+ ( entity_source_atom(Props, Source)
1339
+ -> assertz(kb_index_source(Source, Id)),
1340
+ index_text_tokens(Source, SourceTokens),
1341
+ forall(member(Token, SourceTokens), assertz(kb_index_token(Token, Id)))
1342
+ ; true
1343
+ ),
1344
+ ( memberchk(title=TitleValue, Props)
1345
+ -> normalize_term_atom(TitleValue, Title),
1346
+ index_text_tokens(Title, TitleTokens),
1347
+ forall(member(Token, TitleTokens), assertz(kb_index_token(Token, Id)))
1348
+ ; true
1349
+ ),
1350
+ index_text_tokens(Id, IdTokens),
1351
+ forall(member(Token, IdTokens), assertz(kb_index_token(Token, Id))),
1352
+ index_text_tokens(Type, TypeTokens),
1353
+ forall(member(Token, TypeTokens), assertz(kb_index_token(Token, Id))),
1354
+ forall(
1355
+ ( member(SearchKey, [owner, priority, severity, text_ref, semantic_text]),
1356
+ memberchk(SearchKey=SearchValue, Props) ),
1357
+ ( index_text_tokens(SearchValue, SearchTokens),
1358
+ forall(member(Token, SearchTokens), assertz(kb_index_token(Token, Id))) )
1359
+ ),
1360
+ ( memberchk(tags=RawTags, Props), is_list(RawTags)
1361
+ -> forall(member(RawTag, RawTags),
1362
+ ( normalize_term_atom(RawTag, Tag),
1363
+ assertz(kb_index_tag(Tag, Id)),
1364
+ index_text_tokens(Tag, TagTokens),
1365
+ forall(member(Token, TagTokens), assertz(kb_index_token(Token, Id)))
1366
+ ))
1367
+ ; true
1368
+ ),
1369
+ ( Type == symbol,
1370
+ memberchk(sourceLine=Line, Props),
1371
+ memberchk(sourceColumn=Column, Props),
1372
+ memberchk(sourceEndLine=EndLine, Props),
1373
+ memberchk(sourceEndColumn=EndColumn, Props)
1374
+ -> assertz(kb_index_symbol_coordinate(Id, Line, Column, EndLine, EndColumn))
1375
+ ; true
1376
+ ).
1377
+
1378
+ index_text_tokens(Value, Tokens) :-
1379
+ normalize_term_atom(Value, Atom),
1380
+ downcase_atom(Atom, Lower),
1381
+ atomic_list_concat(Parts, ' ', Lower),
1382
+ findall(Token,
1383
+ ( member(Part, Parts),
1384
+ atom_string(Part, PartString),
1385
+ split_string(PartString, "-_./:#", "-_./:#", Strings),
1386
+ member(String, Strings),
1387
+ String \= '',
1388
+ atom_string(Token, String)
1389
+ ),
1390
+ RawTokens),
1391
+ sort(RawTokens, Tokens).
1392
+
409
1393
  % Convert legacy property list format to Key=Value pairs
410
1394
  convert_legacy_props([], []).
411
1395
  convert_legacy_props([Prop|Rest], [Key=Value|OutRest]) :-
@@ -422,13 +1406,130 @@ convert_legacy_prop(Prop, Prop, true).
422
1406
  %% kb_entities_by_source(+SourcePath, -Ids)
423
1407
  % Returns all entity IDs whose source property matches SourcePath (substring match).
424
1408
  kb_entities_by_source(SourcePath, Ids) :-
1409
+ kb_ensure_indexes,
1410
+ source_value_atom(SourcePath, SourceQuery),
425
1411
  findall(Id,
426
- (kb_entity(Id, _Type, Props),
427
- entity_source_atom(Props, SourceAtom),
428
- sub_atom(SourceAtom, _, _, _, SourcePath)),
429
- RawIds),
1412
+ ( kb_index_source(SourceAtom, Id),
1413
+ sub_atom(SourceAtom, _, _, _, SourceQuery) ),
1414
+ RawIds),
1415
+ sort(RawIds, Ids).
1416
+
1417
+ %% kb_entities_by_tag(+Tag, -Ids)
1418
+ % Index-backed tag lookup used by exact/paginated discovery queries.
1419
+ kb_entities_by_tag(Tag, Ids) :-
1420
+ kb_ensure_indexes,
1421
+ normalize_term_atom(Tag, NormalizedTag),
1422
+ findall(Id, kb_index_tag(NormalizedTag, Id), RawIds),
430
1423
  sort(RawIds, Ids).
431
1424
 
1425
+ %% kb_indexed_sources(-Sources)
1426
+ % Return unique normalized source paths for freshness checks without
1427
+ % materializing every entity property list.
1428
+ kb_indexed_sources(Sources) :-
1429
+ kb_ensure_indexes,
1430
+ ( setof(Source, Id^kb_index_source(Source, Id), Sources)
1431
+ -> true
1432
+ ; Sources = []
1433
+ ).
1434
+
1435
+ %% kb_query_entities(+Type, +Id, +Tags, +Source, +Limit, +Offset, -Rows, -Count)
1436
+ % Index-backed exact/paginated discovery. Only the requested page is
1437
+ % materialized into full property lists; RDF remains authoritative for the
1438
+ % final rows and the index only supplies candidate IDs.
1439
+ kb_query_entities(TypeFilter, IdFilter, Tags, SourceFilter, Limit, Offset, Rows, Count) :-
1440
+ integer(Limit),
1441
+ integer(Offset),
1442
+ Limit >= 0,
1443
+ Offset >= 0,
1444
+ is_list(Tags),
1445
+ kb_ensure_indexes,
1446
+ findall(Id,
1447
+ indexed_entity_match(TypeFilter, IdFilter, Tags, SourceFilter, Id),
1448
+ RawIds),
1449
+ sort(RawIds, Ids),
1450
+ length(Ids, Count),
1451
+ drop_index_ids(Offset, Ids, Remaining),
1452
+ take_index_ids(Limit, Remaining, PageIds),
1453
+ findall([Id, Type, Props],
1454
+ ( member(Id, PageIds),
1455
+ kb_entity(Id, Type, Props) ),
1456
+ Rows).
1457
+
1458
+ indexed_entity_match(TypeFilter, IdFilter, Tags, SourceFilter, Id) :-
1459
+ ( IdFilter == none
1460
+ -> kb_index_entity(Id, _)
1461
+ ; Id = IdFilter,
1462
+ kb_index_entity(Id, _)
1463
+ ),
1464
+ ( TypeFilter == none
1465
+ -> true
1466
+ ; kb_index_type(TypeFilter, Id)
1467
+ ),
1468
+ ( Tags == []
1469
+ -> true
1470
+ ; member(Tag, Tags),
1471
+ kb_index_tag(Tag, Id)
1472
+ ),
1473
+ ( SourceFilter == none
1474
+ -> true
1475
+ ; source_value_atom(SourceFilter, SourceQuery),
1476
+ kb_index_source(Source, Id),
1477
+ sub_atom(Source, _, _, _, SourceQuery)
1478
+ ).
1479
+
1480
+ drop_index_ids(0, Ids, Ids) :- !.
1481
+ drop_index_ids(_, [], []) :- !.
1482
+ drop_index_ids(Offset, [_|Rest], Remaining) :-
1483
+ Next is Offset - 1,
1484
+ drop_index_ids(Next, Rest, Remaining).
1485
+
1486
+ take_index_ids(0, _, []) :- !.
1487
+ take_index_ids(_, [], []) :- !.
1488
+ take_index_ids(Limit, [Id|Rest], [Id|Page]) :-
1489
+ Next is Limit - 1,
1490
+ take_index_ids(Next, Rest, Page).
1491
+
1492
+ %% kb_search_entities(+Type, +Query, +Limit, +Offset, -Rows, -Count)
1493
+ % Return an index-filtered page for the higher-level deterministic ranking
1494
+ % contract. Every normalized query token must match at least one indexed token
1495
+ % for the entity. The TypeScript ranker remains responsible for scores,
1496
+ % reasons, snippets, and final ordering.
1497
+ kb_search_entities(TypeFilter, Query, Limit, Offset, Rows, Count) :-
1498
+ integer(Limit),
1499
+ integer(Offset),
1500
+ Limit >= 0,
1501
+ Offset >= 0,
1502
+ kb_ensure_indexes,
1503
+ index_text_tokens(Query, QueryTokens),
1504
+ QueryTokens \= [],
1505
+ findall(Id,
1506
+ ( kb_index_entity(Id, _),
1507
+ (TypeFilter == none -> true ; kb_index_type(TypeFilter, Id)),
1508
+ ( indexed_markdown_source(Id)
1509
+ ; forall(member(QueryToken, QueryTokens),
1510
+ indexed_token_match(QueryToken, Id)) ) ),
1511
+ RawIds),
1512
+ sort(RawIds, Ids),
1513
+ length(Ids, Count),
1514
+ drop_index_ids(Offset, Ids, Remaining),
1515
+ take_index_ids(Limit, Remaining, PageIds),
1516
+ findall([Id, Type, Props],
1517
+ ( member(Id, PageIds), kb_entity(Id, Type, Props) ),
1518
+ Rows).
1519
+
1520
+ indexed_token_match(QueryToken, Id) :-
1521
+ kb_index_token(IndexedToken, Id),
1522
+ sub_atom(IndexedToken, _, _, _, QueryToken),
1523
+ !.
1524
+
1525
+ % Markdown bodies remain outside RDF and are ranked by the Node discovery
1526
+ % layer. Keep those usually-small document entities in the candidate set so
1527
+ % indexed search does not change the public body-search contract. Large symbol
1528
+ % manifests still take the fast token-index path.
1529
+ indexed_markdown_source(Id) :-
1530
+ kb_index_source(Source, Id),
1531
+ sub_atom(Source, _, _, _, '.md').
1532
+
432
1533
  entity_source_atom(Props, SourceAtom) :-
433
1534
  ( memberchk(sourceFile=RawSourceFile, Props)
434
1535
  -> source_value_atom(RawSourceFile, SourceAtom)
@@ -449,8 +1550,15 @@ source_value_atom(Value, Atom) :-
449
1550
  %% kb_assert_relationship(+Type, +From, +To, +Metadata)
450
1551
  % Assert a relationship between two entities with validation.
451
1552
  kb_assert_relationship(RelType, FromId, ToId, Metadata) :-
452
- kb_assert_relationship_no_audit(RelType, FromId, ToId, Metadata),
453
- kb_log_relationship_upsert(RelType, FromId, ToId, Metadata).
1553
+ ( kb_storage_mode(journaled)
1554
+ -> kb_run_journaled_mutation((
1555
+ kb_assert_relationship_no_audit(RelType, FromId, ToId, Metadata),
1556
+ kb_log_relationship_upsert(RelType, FromId, ToId, Metadata)
1557
+ ))
1558
+ ; kb_mark_dirty,
1559
+ kb_assert_relationship_no_audit(RelType, FromId, ToId, Metadata),
1560
+ kb_log_relationship_upsert(RelType, FromId, ToId, Metadata)
1561
+ ).
454
1562
 
455
1563
  %% kb_assert_relationship_no_audit(+Type, +From, +To, +Metadata)
456
1564
  % Assert a relationship RDF payload without recording audit side effects.
@@ -516,6 +1624,12 @@ mixed_symbol_role(covered_by, SymbolId) :-
516
1624
 
517
1625
  %% kb_log_relationship_upsert(+Type, +From, +To, +Metadata)
518
1626
  % Append the audit entry for a successfully committed relationship upsert.
1627
+ kb_log_relationship_upsert(RelType, FromId, ToId, _Metadata) :-
1628
+ kb_storage_mode(journaled),
1629
+ !,
1630
+ format(atom(RelId), '~w->~w', [FromId, ToId]),
1631
+ kb_assert_audit_event(upsert_rel, RelId,
1632
+ RelType-[from=FromId, to=ToId]).
519
1633
  kb_log_relationship_upsert(RelType, FromId, ToId, _Metadata) :-
520
1634
  with_kb_mutex((
521
1635
  get_time(Timestamp),
@@ -524,6 +1638,65 @@ kb_log_relationship_upsert(RelType, FromId, ToId, _Metadata) :-
524
1638
  assert_changeset(TS, upsert_rel, RelId, RelType-[from=FromId, to=ToId])
525
1639
  )).
526
1640
 
1641
+ % Journaled audit records live in the same RDF graph and transaction as the
1642
+ % domain mutation. This keeps audit durability atomic without a second
1643
+ % persistency lock or a second authoritative database.
1644
+ kb_assert_audit_event(Operation, EntityId, Data) :-
1645
+ kb_graph(Graph),
1646
+ get_time(Timestamp),
1647
+ format_time(atom(TS), '%FT%T%:z', Timestamp),
1648
+ term_to_atom(Data, DataAtom),
1649
+ term_to_atom(EntityId, EntityAtom),
1650
+ term_to_atom(Operation, OperationAtom),
1651
+ next_audit_sequence(Sequence),
1652
+ format(atom(AuditURI), 'urn:kibi:audit/~16f-~w-~w-~w',
1653
+ [Timestamp, EntityId, Operation, Sequence]),
1654
+ with_kb_mutex((
1655
+ rdf_assert(AuditURI, 'urn:kibi:audit/operation',
1656
+ OperationAtom^^'http://www.w3.org/2001/XMLSchema#string', Graph),
1657
+ rdf_assert(AuditURI, 'urn:kibi:audit/timestamp',
1658
+ TS^^'http://www.w3.org/2001/XMLSchema#string', Graph),
1659
+ rdf_assert(AuditURI, 'urn:kibi:audit/entity',
1660
+ EntityAtom^^'http://www.w3.org/2001/XMLSchema#string', Graph),
1661
+ rdf_assert(AuditURI, 'urn:kibi:audit/data',
1662
+ DataAtom^^'http://www.w3.org/2001/XMLSchema#string', Graph)
1663
+ )).
1664
+
1665
+ initialize_journal_audit_sequence(GraphURI) :-
1666
+ aggregate_all(count,
1667
+ rdf(_, 'urn:kibi:audit/operation', _, GraphURI),
1668
+ Count),
1669
+ retractall(kb_audit_sequence(_)),
1670
+ assert(kb_audit_sequence(Count)).
1671
+
1672
+ next_audit_sequence(Sequence) :-
1673
+ ( retract(kb_audit_sequence(Previous))
1674
+ -> Sequence is Previous + 1
1675
+ ; Sequence = 1
1676
+ ),
1677
+ assertz(kb_audit_sequence(Sequence)).
1678
+
1679
+ kb_assert_audit_event_with_timestamp(TS, Operation, EntityId, Data) :-
1680
+ kb_assert_audit_event_with_timestamp(TS, Operation, EntityId, Data, 0).
1681
+
1682
+ kb_assert_audit_event_with_timestamp(TS, Operation, EntityId, Data, Index) :-
1683
+ kb_graph(Graph),
1684
+ term_to_atom(Data, DataAtom),
1685
+ term_to_atom(EntityId, EntityAtom),
1686
+ term_to_atom(Operation, OperationAtom),
1687
+ format(atom(AuditURI), 'urn:kibi:audit/~w-~w-~w-~w',
1688
+ [TS, EntityId, Operation, Index]),
1689
+ with_kb_mutex((
1690
+ rdf_assert(AuditURI, 'urn:kibi:audit/operation',
1691
+ OperationAtom^^'http://www.w3.org/2001/XMLSchema#string', Graph),
1692
+ rdf_assert(AuditURI, 'urn:kibi:audit/timestamp',
1693
+ TS^^'http://www.w3.org/2001/XMLSchema#string', Graph),
1694
+ rdf_assert(AuditURI, 'urn:kibi:audit/entity',
1695
+ EntityAtom^^'http://www.w3.org/2001/XMLSchema#string', Graph),
1696
+ rdf_assert(AuditURI, 'urn:kibi:audit/data',
1697
+ DataAtom^^'http://www.w3.org/2001/XMLSchema#string', Graph)
1698
+ )).
1699
+
527
1700
  %% validate_strict_lane_pairing(+RelType, +FromId, +ToId)
528
1701
  % Validate that constrains/requires_property relationships target facts
529
1702
  % with the correct fact_kind for strict-lane semantics.
@@ -600,6 +1773,7 @@ entity_id_to_uri(Id, URI) :-
600
1773
  %% entity_uri_to_id(+URI, -Id)
601
1774
  % Extract entity ID from canonical or legacy entity URIs.
602
1775
  entity_uri_to_id(URI, Id) :-
1776
+ atom(URI),
603
1777
  ( kb_uri(BaseURI),
604
1778
  atom_concat(BaseURI, 'entity/', Prefix),
605
1779
  atom_concat(Prefix, Id, URI)
@@ -625,7 +1799,7 @@ store_property(EntityURI, Key, Value, Graph) :-
625
1799
  % Key-aware: typed fact fields get specific XSD datatypes.
626
1800
  value_to_literal(Key, Value, Literal) :-
627
1801
  % Typed fact fields with specific XSD datatypes
628
- ( Key == value_int, integer(Value)
1802
+ ( integer_property_key(Key), integer(Value)
629
1803
  -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#integer'
630
1804
  ; Key == value_number, number(Value)
631
1805
  -> Literal = Value^^'http://www.w3.org/2001/XMLSchema#decimal'
@@ -643,9 +1817,29 @@ value_to_literal(Key, Value, Literal) :-
643
1817
  Literal = Str^^'http://www.w3.org/2001/XMLSchema#string'
644
1818
  ).
645
1819
 
646
- %% literal_to_value(+Literal, -Value)
647
- % Extract value from RDF literal, parse list syntax back to Prolog lists.
648
- literal_to_value(Literal, Value) :-
1820
+ integer_property_key(Key) :-
1821
+ memberchk(Key, [
1822
+ value_int,
1823
+ predicate_arity,
1824
+ claim_span_start,
1825
+ claim_span_end,
1826
+ sourceLine,
1827
+ sourceColumn,
1828
+ sourceEndLine,
1829
+ sourceEndColumn
1830
+ ]).
1831
+
1832
+ %% literal_to_value(+Key, +Literal, -Value)
1833
+ % Extract a property value from an RDF literal. JSON-backed fields must remain
1834
+ % intact: JSON arrays are also valid Prolog list syntax, but parsing them as
1835
+ % Prolog terms corrupts their object entries before the JSON decoder sees them.
1836
+ literal_to_value(semantic_inventory, Literal, Value) :-
1837
+ !,
1838
+ structured_literal_value(Literal, Value).
1839
+ literal_to_value(verification_receipts, Literal, Value) :-
1840
+ !,
1841
+ structured_literal_value(Literal, Value).
1842
+ literal_to_value(_Key, Literal, Value) :-
649
1843
  ( % Handle ^^/2 functor (RDF typed literal shorthand)
650
1844
  Literal = ^^(StrVal, 'http://www.w3.org/2001/XMLSchema#string')
651
1845
  -> ( % Preserve RDF typed literal functor for string values so callers
@@ -678,6 +1872,12 @@ literal_to_value(Literal, Value) :-
678
1872
  ; Value = Literal
679
1873
  ).
680
1874
 
1875
+ structured_literal_value(^^(Value, _), Value) :- !.
1876
+ structured_literal_value(literal(type(_, Value)), Value) :- !.
1877
+ structured_literal_value(literal(lang(_, Value)), Value) :- !.
1878
+ structured_literal_value(literal(Value), Value) :- !.
1879
+ structured_literal_value(Value, Value).
1880
+
681
1881
  %% literal_to_atom(+Literal, -Atom)
682
1882
  % Convert RDF literal to atom (for type field).
683
1883
  literal_to_atom(Literal, Atom) :-
@@ -979,19 +2179,21 @@ current_req(Id) :-
979
2179
  % semantic conflicts (same subject/property but incompatible values or polarities).
980
2180
  % Checks in order of specificity: polarity conflicts, then value conflicts.
981
2181
  contradicting_reqs(ReqA, ReqB, Reason) :-
982
- current_req(ReqA),
983
- current_req(ReqB),
984
- ReqA @< ReqB,
985
- req_conflict(ReqA, ReqB, Reason).
2182
+ req_conflict_witness(ReqA, ReqB, Witness),
2183
+ Reason = Witness.reason.
986
2184
 
987
2185
  %% ------------------------------------------------------------------
988
2186
  %% Semantic Contradiction Helpers (Task 4)
989
2187
  %% ------------------------------------------------------------------
990
2188
 
991
- %% req_conflict(+ReqA, +ReqB, -Reason)
992
- % Detects conflicts between requirements via shared subject facts and
993
- % incompatible property_value facts linked through requires_property.
994
- req_conflict(ReqA, ReqB, Reason) :-
2189
+ %% req_conflict_witness(+ReqA, +ReqB, -Witness)
2190
+ % Return exact, source-bound evidence for one proven strict or predicate
2191
+ % contradiction. This remains deliberately syntactic: differently shaped
2192
+ % terms are never treated as equivalent merely because their prose is similar.
2193
+ req_conflict_witness(ReqA, ReqB, Witness) :-
2194
+ current_req(ReqA),
2195
+ current_req(ReqB),
2196
+ ReqA @< ReqB,
995
2197
  kb_relationship(constrains, ReqA, SubjectFactA),
996
2198
  kb_relationship(constrains, ReqB, SubjectFactB),
997
2199
  fact_subject_key(SubjectFactA, SubjectKey),
@@ -1005,8 +2207,23 @@ req_conflict(ReqA, ReqB, Reason) :-
1005
2207
  OpB, ValTypeB, ValB, UnitB, ScopeB, PolarityB, Reason)
1006
2208
  ; property_conflict(SubjectKey, PropertyKey, OpA, ValTypeA, ValA, UnitA, PolarityA,
1007
2209
  OpB, ValTypeB, ValB, UnitB, PolarityB, Reason)
1008
- ).
1009
- req_conflict(ReqA, ReqB, Reason) :-
2210
+ ),
2211
+ property_conflict_side(ReqA, FactA, SubjectKey, PropertyKey, OpA, ValTypeA, ValA, UnitA, ScopeA, PolarityA, ValidFromA, ValidToA, Left),
2212
+ property_conflict_side(ReqB, FactB, SubjectKey, PropertyKey, OpB, ValTypeB, ValB, UnitB, ScopeB, PolarityB, ValidFromB, ValidToB, Right),
2213
+ Witness = _{
2214
+ kind: strict_property,
2215
+ status: contradiction,
2216
+ requirements: [ReqA, ReqB],
2217
+ reason: Reason,
2218
+ subjectKey: SubjectKey,
2219
+ propertyKey: PropertyKey,
2220
+ left: Left,
2221
+ right: Right
2222
+ }.
2223
+ req_conflict_witness(ReqA, ReqB, Witness) :-
2224
+ current_req(ReqA),
2225
+ current_req(ReqB),
2226
+ ReqA @< ReqB,
1010
2227
  effective_req_predicate(ReqA, FactA, Namespace, Name, Args, PolarityA),
1011
2228
  effective_req_predicate(ReqB, FactB, Namespace, Name, Args, PolarityB),
1012
2229
  FactA \= FactB,
@@ -1016,6 +2233,86 @@ req_conflict(ReqA, ReqB, Reason) :-
1016
2233
  string(Reason),
1017
2234
  "Predicate conflict on ~w:~w(~w): ~w asserts ~w while ~w asserts ~w",
1018
2235
  [Namespace, Name, ArgsText, ReqA, PolarityA, ReqB, PolarityB]
2236
+ ),
2237
+ predicate_conflict_side(ReqA, FactA, Namespace, Name, Args, PolarityA, Left),
2238
+ predicate_conflict_side(ReqB, FactB, Namespace, Name, Args, PolarityB, Right),
2239
+ length(Args, Arity),
2240
+ findall(SchemaId, predicate_schema(SchemaId, Namespace, Name, Arity, _ArgumentNames, _ArgumentTypes), SchemaIds0),
2241
+ sort(SchemaIds0, SchemaIds),
2242
+ Witness = _{
2243
+ kind: predicate,
2244
+ status: contradiction,
2245
+ requirements: [ReqA, ReqB],
2246
+ reason: Reason,
2247
+ predicateNamespace: Namespace,
2248
+ predicateName: Name,
2249
+ predicateArgs: Args,
2250
+ schemaIds: SchemaIds,
2251
+ left: Left,
2252
+ right: Right
2253
+ }.
2254
+
2255
+ property_conflict_side(ReqId, FactId, SubjectKey, PropertyKey, Operator, ValueType, Value, Unit, Scope, Polarity, ValidFrom, ValidTo, Side) :-
2256
+ entity_evidence_source(ReqId, req, RequirementSource),
2257
+ fact_evidence_fields(FactId, FactSource, ClaimKey, ClaimText, _CanonicalKey),
2258
+ Side = _{
2259
+ requirementId: ReqId,
2260
+ requirementSource: RequirementSource,
2261
+ factId: FactId,
2262
+ factSource: FactSource,
2263
+ claimKey: ClaimKey,
2264
+ claimText: ClaimText,
2265
+ term: _{
2266
+ subjectKey: SubjectKey,
2267
+ propertyKey: PropertyKey,
2268
+ operator: Operator,
2269
+ valueType: ValueType,
2270
+ value: Value,
2271
+ unit: Unit,
2272
+ scope: Scope,
2273
+ polarity: Polarity,
2274
+ validFrom: ValidFrom,
2275
+ validTo: ValidTo
2276
+ }
2277
+ }.
2278
+
2279
+ predicate_conflict_side(ReqId, FactId, Namespace, Name, Args, Polarity, Side) :-
2280
+ entity_evidence_source(ReqId, req, RequirementSource),
2281
+ fact_evidence_fields(FactId, FactSource, ClaimKey, ClaimText, CanonicalKey),
2282
+ Side = _{
2283
+ requirementId: ReqId,
2284
+ requirementSource: RequirementSource,
2285
+ factId: FactId,
2286
+ factSource: FactSource,
2287
+ claimKey: ClaimKey,
2288
+ claimText: ClaimText,
2289
+ term: _{
2290
+ namespace: Namespace,
2291
+ name: Name,
2292
+ args: Args,
2293
+ polarity: Polarity,
2294
+ canonicalKey: CanonicalKey
2295
+ }
2296
+ }.
2297
+
2298
+ entity_evidence_source(EntityId, Type, Source) :-
2299
+ kb_entity(EntityId, Type, Props),
2300
+ (memberchk(source=RawSource, Props) -> evidence_term_atom(RawSource, Source) ; Source = '').
2301
+
2302
+ fact_evidence_fields(FactId, Source, ClaimKey, ClaimText, CanonicalKey) :-
2303
+ kb_entity(FactId, fact, Props),
2304
+ (memberchk(source=RawSource, Props) -> evidence_term_atom(RawSource, Source) ; Source = ''),
2305
+ (memberchk(claim_key=RawClaimKey, Props) -> evidence_term_atom(RawClaimKey, ClaimKey) ; ClaimKey = ''),
2306
+ (memberchk(claim_text=RawClaimText, Props) -> evidence_term_atom(RawClaimText, ClaimText) ; ClaimText = ''),
2307
+ (memberchk(canonical_key=RawCanonicalKey, Props) -> evidence_term_atom(RawCanonicalKey, CanonicalKey) ; CanonicalKey = '').
2308
+
2309
+ evidence_term_atom(Raw, Atom) :-
2310
+ unwrap_rdf_value(Raw, Value),
2311
+ ( atom(Value)
2312
+ -> Atom = Value
2313
+ ; string(Value)
2314
+ -> atom_string(Atom, Value)
2315
+ ; term_string(Value, String), atom_string(Atom, String)
1019
2316
  ).
1020
2317
 
1021
2318
  %% effective_req_predicate(+ReqId, -FactId, -Namespace, -Name, -Args, -Polarity)