kibi-core 0.9.1 → 0.11.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,6 +4,12 @@
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,
@@ -12,6 +18,13 @@
12
18
  kb_retract_entity/1,
13
19
  kb_retract_entity/3,
14
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,
15
28
  kb_entity/3,
16
29
  kb_entities_by_source/2,
17
30
  kb_assert_relationship/4,
@@ -53,9 +66,13 @@
53
66
  ]).
54
67
 
55
68
  :- use_module(library(semweb/rdf11)).
69
+ :- use_module(library(semweb/rdf_persistency)).
56
70
  :- use_module(library(persistency)).
57
71
  :- use_module(library(thread)).
58
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]).
59
76
  :- use_module(library(ordsets)).
60
77
  :- use_module('../schema/entities.pl', [entity_type/1, entity_property/3, required_property/2]).
61
78
  :- use_module('../schema/relationships.pl', [relationship_type/1, valid_relationship/3]).
@@ -80,10 +97,31 @@ kb_uri('urn-kibi:').
80
97
  :- dynamic kb_audit_db/1.
81
98
  :- dynamic kb_graph/1.
82
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.
83
119
  :- dynamic entity/4. % Support legacy .pl file format (Type, Id, Title, Props)
84
120
 
85
121
  %% kb_attach(+Directory)
86
- % 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.
87
125
  % Creates directory if it doesn't exist.
88
126
  kb_attach(Directory) :-
89
127
  ( kb_attached(_)
@@ -95,6 +133,67 @@ kb_attach(Directory) :-
95
133
  -> true
96
134
  ; make_directory_path(Directory)
97
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) :-
98
197
  % Create RDF graph name from directory
99
198
  atom_concat('file://', Directory, GraphURI),
100
199
  % If a graph with this URI is already present, unload it to avoid duplicates.
@@ -122,10 +221,12 @@ kb_attach(Directory) :-
122
221
  assert(kb_attached(Directory)),
123
222
  assert(kb_audit_db(AuditLog)),
124
223
  assert(kb_graph(GraphURI)),
224
+ assert(kb_storage_mode(legacy)),
125
225
  assert(kb_attached_snapshot(SnapshotStamp)),
126
226
 
127
227
  % Load legacy .pl entity files if present
128
- load_kb_pl_files(Directory).
228
+ load_kb_pl_files(Directory),
229
+ kb_rebuild_indexes.
129
230
 
130
231
 
131
232
  %% kb_detach
@@ -135,6 +236,11 @@ kb_attach(Directory) :-
135
236
  kb_detach :-
136
237
  ( kb_attached(_Directory)
137
238
  -> (
239
+ ( kb_storage_mode(journaled)
240
+ -> catch(rdf_flush_journals([min_size(16384)]), _, true),
241
+ catch(rdf_detach_db, _, true)
242
+ ; true
243
+ ),
138
244
  % Unload RDF graph from memory to prevent duplication on reattach
139
245
  ( kb_graph(GraphURI)
140
246
  -> rdf_unload_graph(GraphURI)
@@ -148,7 +254,24 @@ kb_detach :-
148
254
  retractall(kb_attached(_)),
149
255
  retractall(kb_audit_db(_)),
150
256
  retractall(kb_graph(_)),
151
- 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(_))
152
275
  )
153
276
  ; true
154
277
  ).
@@ -157,7 +280,9 @@ kb_detach :-
157
280
  % Save RDF graph and sync audit log to disk
158
281
  % implements REQ-009
159
282
  kb_save :-
160
- ( kb_attached(Directory)
283
+ ( kb_storage_mode(journaled)
284
+ -> kb_save_journaled
285
+ ; kb_attached(Directory)
161
286
  -> with_kb_mutex(with_kb_file_lock(Directory, kb_save_locked(Directory)))
162
287
  ; true
163
288
  ).
@@ -171,6 +296,12 @@ with_kb_file_lock(Directory, Goal) :-
171
296
  ).
172
297
 
173
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) :-
174
305
  atom_concat(Directory, '/kb.rdf', DataFile),
175
306
  temp_rdf_file(Directory, TempFile),
176
307
  catch(
@@ -196,6 +327,486 @@ kb_save_locked(Directory) :-
196
327
  )
197
328
  ).
198
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
+
199
810
  temp_rdf_file(Directory, TempFile) :-
200
811
  get_time(Timestamp),
201
812
  Millis is floor(Timestamp * 1000),
@@ -245,6 +856,22 @@ ensure_snapshot_current(DataFile) :-
245
856
  with_kb_mutex(Goal) :-
246
857
  with_mutex(kb_lock, Goal).
247
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
+
248
875
  %% load_kb_pl_files(+Directory)
249
876
  % Load legacy .pl entity files from the KB directory.
250
877
  % These files use entity/4 format: entity(Type, Id, Title, Props).
@@ -275,8 +902,15 @@ kb_assert_entity(Type, Props) :-
275
902
  -> ChangeKind = updated
276
903
  ; ChangeKind = created
277
904
  ),
278
- kb_assert_entity_no_audit(Type, Props),
279
- 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
+ ).
280
914
 
281
915
  %% kb_assert_entity_no_audit(+Type, +Properties)
282
916
  % Assert an entity RDF payload without recording audit side effects.
@@ -313,7 +947,8 @@ kb_assert_entity_no_audit(Type, Props) :-
313
947
  member(Key=Value, Props),
314
948
  store_property(EntityURI, Key, Value, Graph)
315
949
  )
316
- )).
950
+ )),
951
+ kb_refresh_entity_index(Id).
317
952
 
318
953
  %% kb_commit_upsert(+Type, +Properties, +Relationships, +SkipContradiction, -ChangeKind)
319
954
  % Commit an entity upsert, its relationships, audit entries, and RDF snapshot
@@ -322,6 +957,33 @@ kb_assert_entity_no_audit(Type, Props) :-
322
957
  % Relationships use rel(Type, FromId, ToId, Metadata) terms. Metadata is
323
958
  % retained for the caller's serialization contract; the audit schema records
324
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
+ )).
325
987
  kb_commit_upsert(Type, Props, Relationships, SkipContradiction, ChangeKind) :-
326
988
  memberchk(id=Id, Props),
327
989
  memberchk(SkipContradiction, [true, false]),
@@ -423,19 +1085,91 @@ kb_environment_value(Name, Value) :-
423
1085
  % Used by projectStagedEntities to clear stale relationships before re-asserting.
424
1086
  kb_retract_entity_relationships(Id) :-
425
1087
  kb_graph(Graph),
426
- with_kb_mutex((
427
- entity_id_to_uri(Id, EntityURI),
428
- forall(
429
- ( rdf(EntityURI, RelURI, TargetURI, Graph),
430
- atom(TargetURI),
431
- entity_uri_to_id(TargetURI, _)
432
- ),
433
- 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
+ )
434
1163
  )
435
- )).
1164
+ ).
436
1165
 
437
1166
  %% kb_log_entity_upsert(+ChangeKind, +Type, +Properties)
438
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]).
439
1173
  kb_log_entity_upsert(ChangeKind, Type, Props) :-
440
1174
  memberchk(id=Id, Props),
441
1175
  memberchk(ChangeKind, [created, updated]),
@@ -457,6 +1191,26 @@ kb_retract_entity(Id) :-
457
1191
 
458
1192
  %% kb_retract_entity(+Id, +Type, +AuditProps)
459
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
+ ))).
460
1214
  kb_retract_entity(Id, Type, AuditProps) :-
461
1215
  kb_graph(Graph),
462
1216
  with_kb_mutex((
@@ -467,7 +1221,8 @@ kb_retract_entity(Id, Type, AuditProps) :-
467
1221
  % Log to audit
468
1222
  get_time(Timestamp),
469
1223
  format_time(atom(TS), '%FT%T%:z', Timestamp),
470
- assert_changeset(TS, delete, Id, Type-AuditProps)
1224
+ assert_changeset(TS, delete, Id, Type-AuditProps),
1225
+ kb_remove_entity_index(Id)
471
1226
  )).
472
1227
 
473
1228
  entity_delete_audit_props(Id, Props, AuditProps) :-
@@ -495,6 +1250,20 @@ audit_property_value(RawValue, Value) :-
495
1250
  % Query entities from the KB.
496
1251
  % Properties is unified with a list of Key=Value pairs.
497
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) :-
498
1267
  kb_graph(Graph),
499
1268
  % Find entity by pattern - use unquoted namespace term kb:type
500
1269
  ( var(Id)
@@ -516,10 +1285,111 @@ kb_entity(Id, Type, Props) :-
516
1285
  ), Props).
517
1286
 
518
1287
  % Fallback: read from legacy entity/4 facts loaded from .pl files
519
- kb_entity(Id, Type, Props) :-
1288
+ kb_entity_raw(Id, Type, Props) :-
520
1289
  entity(Type, Id, _Title, PropList),
521
1290
  convert_legacy_props(PropList, Props).
522
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
+
523
1393
  % Convert legacy property list format to Key=Value pairs
524
1394
  convert_legacy_props([], []).
525
1395
  convert_legacy_props([Prop|Rest], [Key=Value|OutRest]) :-
@@ -536,13 +1406,130 @@ convert_legacy_prop(Prop, Prop, true).
536
1406
  %% kb_entities_by_source(+SourcePath, -Ids)
537
1407
  % Returns all entity IDs whose source property matches SourcePath (substring match).
538
1408
  kb_entities_by_source(SourcePath, Ids) :-
1409
+ kb_ensure_indexes,
1410
+ source_value_atom(SourcePath, SourceQuery),
539
1411
  findall(Id,
540
- (kb_entity(Id, _Type, Props),
541
- entity_source_atom(Props, SourceAtom),
542
- sub_atom(SourceAtom, _, _, _, SourcePath)),
543
- 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),
544
1423
  sort(RawIds, Ids).
545
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
+
546
1533
  entity_source_atom(Props, SourceAtom) :-
547
1534
  ( memberchk(sourceFile=RawSourceFile, Props)
548
1535
  -> source_value_atom(RawSourceFile, SourceAtom)
@@ -563,8 +1550,15 @@ source_value_atom(Value, Atom) :-
563
1550
  %% kb_assert_relationship(+Type, +From, +To, +Metadata)
564
1551
  % Assert a relationship between two entities with validation.
565
1552
  kb_assert_relationship(RelType, FromId, ToId, Metadata) :-
566
- kb_assert_relationship_no_audit(RelType, FromId, ToId, Metadata),
567
- 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
+ ).
568
1562
 
569
1563
  %% kb_assert_relationship_no_audit(+Type, +From, +To, +Metadata)
570
1564
  % Assert a relationship RDF payload without recording audit side effects.
@@ -630,6 +1624,12 @@ mixed_symbol_role(covered_by, SymbolId) :-
630
1624
 
631
1625
  %% kb_log_relationship_upsert(+Type, +From, +To, +Metadata)
632
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]).
633
1633
  kb_log_relationship_upsert(RelType, FromId, ToId, _Metadata) :-
634
1634
  with_kb_mutex((
635
1635
  get_time(Timestamp),
@@ -638,6 +1638,65 @@ kb_log_relationship_upsert(RelType, FromId, ToId, _Metadata) :-
638
1638
  assert_changeset(TS, upsert_rel, RelId, RelType-[from=FromId, to=ToId])
639
1639
  )).
640
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
+
641
1700
  %% validate_strict_lane_pairing(+RelType, +FromId, +ToId)
642
1701
  % Validate that constrains/requires_property relationships target facts
643
1702
  % with the correct fact_kind for strict-lane semantics.
@@ -714,6 +1773,7 @@ entity_id_to_uri(Id, URI) :-
714
1773
  %% entity_uri_to_id(+URI, -Id)
715
1774
  % Extract entity ID from canonical or legacy entity URIs.
716
1775
  entity_uri_to_id(URI, Id) :-
1776
+ atom(URI),
717
1777
  ( kb_uri(BaseURI),
718
1778
  atom_concat(BaseURI, 'entity/', Prefix),
719
1779
  atom_concat(Prefix, Id, URI)
@@ -779,6 +1839,9 @@ literal_to_value(semantic_inventory, Literal, Value) :-
779
1839
  literal_to_value(verification_receipts, Literal, Value) :-
780
1840
  !,
781
1841
  structured_literal_value(Literal, Value).
1842
+ literal_to_value(verification_contract, Literal, Value) :-
1843
+ !,
1844
+ structured_literal_value(Literal, Value).
782
1845
  literal_to_value(_Key, Literal, Value) :-
783
1846
  ( % Handle ^^/2 functor (RDF typed literal shorthand)
784
1847
  Literal = ^^(StrVal, 'http://www.w3.org/2001/XMLSchema#string')