kibi-core 0.18.2 → 0.20.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/discovery.pl +449 -1
  3. package/src/kb.pl +150 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-core",
3
- "version": "0.18.2",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "description": "Core Prolog modules and RDF graph logic for Kibi",
6
6
  "type": "module",
package/src/discovery.pl CHANGED
@@ -12,7 +12,11 @@
12
12
  requirement_proof_json/7,
13
13
  symbol_proof_json/7,
14
14
  graph_expand_json/8,
15
- search_answer_verdicts_json/2
15
+ search_answer_verdicts_json/2,
16
+ ask_predicate_json/9,
17
+ ask_property_json/6,
18
+ ask_schemas_json/5,
19
+ ask_fact_kind_json/4
16
20
  ]).
17
21
 
18
22
  % Source contains non-ASCII text; do not depend on the host locale.
@@ -471,6 +475,450 @@ graph_expand_json(SeedIds, Relationships, Direction, Depth, EntityTypes, MaxNode
471
475
  Response = _{nodes: Nodes, edges: Edges, truncated: Truncated, meta: Meta},
472
476
  dict_json_string(Response, JsonString).
473
477
 
478
+ %% ask_predicate_json(+Namespace, +Name, +ArgSpec, +PolarityFilter, +Scope, +DistinctArg, +Limit, +Offset, -JsonString)
479
+ % Typed query over ontology predicate facts (fact_kind: predicate). Namespace
480
+ % `none` matches any namespace. ArgSpec is either a positional list of
481
+ % constants where the atom '$any' is a wildcard ([] matches any arguments),
482
+ % or where(Pairs) with Name=Value entries resolved through the predicate's
483
+ % declared argument names (schema or built-in signature). PolarityFilter is
484
+ % `none`, assert or deny; Scope `current_requirements` keeps only facts that a
485
+ % current requirement requires; DistinctArg names a declared argument whose
486
+ % distinct values are counted. Rows carry the effective allow/deny decision
487
+ % when the predicate is a decision predicate. An empty answer lists, per
488
+ % bound argument, the values that do exist (suggestions). Read-only.
489
+ % implements REQ-kibi-query-fact-filters
490
+ ask_predicate_json(Namespace, Name, ArgSpec, PolarityFilter, Scope, DistinctArg, Limit, Offset, JsonString) :-
491
+ ask_namespace(Namespace, NamespaceMatch),
492
+ % The schema lookup may bind a namespace; an unnamed namespace must keep
493
+ % matching facts in every namespace, so the schema sees a copy.
494
+ copy_term(NamespaceMatch, SchemaNamespace),
495
+ ask_schema_dict(SchemaNamespace, Name, SchemaDict, ArgumentNames),
496
+ ask_arg_pattern(ArgSpec, ArgumentNames, ArgPattern, PatternWarnings),
497
+ ( PatternWarnings == []
498
+ -> findall(Row,
499
+ ask_predicate_row(NamespaceMatch, Name, ArgPattern, PolarityFilter, Scope, ArgumentNames, Row),
500
+ Rows0)
501
+ ; Rows0 = []
502
+ ),
503
+ sort_dict_rows(Rows0, SortedRows),
504
+ length(SortedRows, Count),
505
+ paginate_rows(SortedRows, Offset, Limit, Rows),
506
+ ask_distinct(DistinctArg, ArgumentNames, SortedRows, Distinct, DistinctWarnings),
507
+ append(PatternWarnings, DistinctWarnings, Warnings),
508
+ ask_suggestions(Count, NamespaceMatch, Name, ArgSpec, ArgumentNames, Suggestions),
509
+ ask_spec_json(ArgSpec, ArgPattern, SpecJson),
510
+ ask_optional_atom(Namespace, NamespaceJson),
511
+ status_meta_dict(Meta),
512
+ Response = _{
513
+ mode: predicate,
514
+ predicate: _{namespace: NamespaceJson, name: Name, args: SpecJson},
515
+ schema: SchemaDict,
516
+ count: Count,
517
+ rows: Rows,
518
+ distinct: Distinct,
519
+ suggestions: Suggestions,
520
+ warnings: Warnings,
521
+ meta: Meta
522
+ },
523
+ dict_json_string(Response, JsonString).
524
+
525
+ %% ask_arg_pattern(+ArgSpec, +ArgumentNames, -ArgPattern, -Warnings)
526
+ % A positional spec is used as is. A where(Pairs) spec becomes a positional
527
+ % pattern over the declared argument names; an undeclared name yields a
528
+ % warning that lists the declared names and no rows.
529
+ ask_arg_pattern(where(Pairs), ArgumentNames, ArgPattern, Warnings) :-
530
+ !,
531
+ ( is_list(ArgumentNames)
532
+ -> findall(Warning,
533
+ ( member(Key=_, Pairs),
534
+ \+ memberchk(Key, ArgumentNames),
535
+ atomic_list_concat(ArgumentNames, ', ', Declared),
536
+ format(atom(Warning), "'~w' is not an argument of this predicate; declared arguments: ~w", [Key, Declared])
537
+ ),
538
+ Warnings),
539
+ findall(Entry,
540
+ ( member(ArgName, ArgumentNames),
541
+ ( memberchk(ArgName=Value, Pairs)
542
+ -> Entry = Value
543
+ ; Entry = '$any'
544
+ )
545
+ ),
546
+ ArgPattern)
547
+ ; Pairs == []
548
+ -> ArgPattern = [],
549
+ Warnings = []
550
+ ; ArgPattern = [],
551
+ Warnings = ["named arguments need a predicate schema; none declares this predicate, so pass positional args instead"]
552
+ ).
553
+ ask_arg_pattern(ArgPattern, ArgumentNames, ArgPattern, Warnings) :-
554
+ ( is_list(ArgumentNames),
555
+ ArgPattern \== [],
556
+ length(ArgPattern, Given),
557
+ length(ArgumentNames, Arity),
558
+ Given =\= Arity
559
+ -> format(atom(Warning), "positional args need one entry per argument (~w declared, ~w given); use null for any value or name them with where", [Arity, Given]),
560
+ Warnings = [Warning]
561
+ ; Warnings = []
562
+ ).
563
+
564
+ ask_spec_json(where(Pairs), _ArgPattern, Json) :-
565
+ !,
566
+ findall(Key-Value, member(Key=Value, Pairs), KeyValues),
567
+ dict_pairs(Json, _, KeyValues).
568
+ ask_spec_json(_ArgSpec, ArgPattern, Json) :-
569
+ ask_pattern_json(ArgPattern, Json).
570
+
571
+ %% ask_suggestions(+Count, +Namespace, +Name, +ArgSpec, +ArgumentNames, -Suggestions)
572
+ % When nothing matched a where(...) spec, list for each bound argument the
573
+ % values that do exist for this predicate so the caller can correct the
574
+ % question instead of reading "no results".
575
+ ask_suggestions(0, Namespace, Name, where(Pairs), ArgumentNames, Suggestions) :-
576
+ is_list(ArgumentNames),
577
+ Pairs \== [],
578
+ findall(Key-Values,
579
+ ( member(Key=_, Pairs),
580
+ nth1(Position, ArgumentNames, Key),
581
+ findall(Value,
582
+ ( kb:predicate_fact(_, Namespace, Name, Args, _),
583
+ nth1(Position, Args, Value)
584
+ ),
585
+ Values0),
586
+ sort(Values0, Values)
587
+ ),
588
+ Entries),
589
+ Entries \== [],
590
+ !,
591
+ dict_pairs(Suggestions, _, Entries).
592
+ ask_suggestions(_Count, _Namespace, _Name, _ArgSpec, _ArgumentNames, null).
593
+
594
+ ask_namespace(none, _) :- !.
595
+ ask_namespace(Namespace, Namespace).
596
+
597
+ ask_optional_atom(none, null) :- !.
598
+ ask_optional_atom(Atom, Atom).
599
+
600
+ ask_pattern_json([], []) :- !.
601
+ ask_pattern_json(Pattern, Json) :-
602
+ maplist(ask_pattern_entry_json, Pattern, Json).
603
+
604
+ ask_pattern_entry_json('$any', null) :- !.
605
+ ask_pattern_entry_json(Value, Value).
606
+
607
+ % Arguments of a stored fact unify with the requested pattern; '$any' entries
608
+ % become fresh variables and an empty pattern accepts any arity.
609
+ ask_pattern_args([], _Args) :- !.
610
+ ask_pattern_args(Pattern, Args) :-
611
+ maplist(ask_pattern_arg, Pattern, Args).
612
+
613
+ ask_pattern_arg('$any', _) :- !.
614
+ ask_pattern_arg(Value, Arg) :-
615
+ kb:normalize_term_atom(Value, Arg).
616
+
617
+ ask_polarity_match(none, _) :- !.
618
+ ask_polarity_match(Polarity, Polarity).
619
+
620
+ % implements REQ-kibi-query-fact-filters
621
+ ask_predicate_row(Namespace, Name, ArgPattern, PolarityFilter, Scope, ArgumentNames, Row) :-
622
+ kb:predicate_fact(FactId, Namespace, Name, Args, Polarity),
623
+ ask_pattern_args(ArgPattern, Args),
624
+ ask_polarity_match(PolarityFilter, Polarity),
625
+ ask_fact_requirements(FactId, requires_predicate, Requirements, HasCurrent),
626
+ ask_scope_match(Scope, HasCurrent),
627
+ ask_fact_text(FactId, Title, Status, ClaimText, CanonicalKey),
628
+ ask_named_arguments(ArgumentNames, Args, NamedArguments),
629
+ ask_effect(Namespace, Name, Args, Polarity, Effect),
630
+ Row = _{
631
+ id: FactId,
632
+ type: fact,
633
+ title: Title,
634
+ status: Status,
635
+ namespace: Namespace,
636
+ name: Name,
637
+ args: Args,
638
+ arguments: NamedArguments,
639
+ polarity: Polarity,
640
+ effect: Effect,
641
+ canonicalKey: CanonicalKey,
642
+ claimText: ClaimText,
643
+ requirements: Requirements
644
+ }.
645
+
646
+ ask_scope_match(current_requirements, true) :- !.
647
+ ask_scope_match(current_requirements, _) :- !, fail.
648
+ ask_scope_match(_Scope, _HasCurrent).
649
+
650
+ %% ask_fact_requirements(+FactId, +Relationship, -Requirements, -HasCurrent)
651
+ % Requirements linked to the fact through Relationship, each with whether it
652
+ % is current (kb:current_req/1); HasCurrent is true when at least one is.
653
+ ask_fact_requirements(FactId, Relationship, Requirements, HasCurrent) :-
654
+ findall(ReqId, kb_relationship(Relationship, ReqId, FactId), ReqIds0),
655
+ sort(ReqIds0, ReqIds),
656
+ maplist(ask_requirement_dict, ReqIds, Requirements),
657
+ ( member(Dict, Requirements), Dict.current == true
658
+ -> HasCurrent = true
659
+ ; HasCurrent = false
660
+ ).
661
+
662
+ ask_requirement_dict(ReqId, _{id: ReqId, status: Status, current: Current}) :-
663
+ ( kb_entity(ReqId, req, Props), memberchk(status=StatusRaw, Props)
664
+ -> source_value_atom(StatusRaw, Status)
665
+ ; Status = ''
666
+ ),
667
+ ( kb:current_req(ReqId) -> Current = true ; Current = false ).
668
+
669
+ ask_fact_text(FactId, Title, Status, ClaimText, CanonicalKey) :-
670
+ kb_entity(FactId, fact, Props),
671
+ ( memberchk(title=TitleRaw, Props) -> source_value_atom(TitleRaw, Title) ; Title = '' ),
672
+ ( memberchk(status=StatusRaw, Props) -> source_value_atom(StatusRaw, Status) ; Status = '' ),
673
+ ( memberchk(claim_text=ClaimRaw, Props) -> source_value_atom(ClaimRaw, ClaimText) ; ClaimText = '' ),
674
+ ( memberchk(canonical_key=KeyRaw, Props) -> source_value_atom(KeyRaw, CanonicalKey) ; CanonicalKey = '' ).
675
+
676
+ % Named arguments follow the schema when it exists and has the same arity;
677
+ % otherwise the row carries only the positional list.
678
+ ask_named_arguments(ArgumentNames, Args, Named) :-
679
+ is_list(ArgumentNames),
680
+ same_length(ArgumentNames, Args),
681
+ !,
682
+ pairs_keys_values(Pairs, ArgumentNames, Args),
683
+ dict_pairs(Named, _, Pairs).
684
+ ask_named_arguments(_ArgumentNames, _Args, null).
685
+
686
+ % The effective allow/deny decision of a decision predicate (a schema with
687
+ % key_arguments and a decision argument, or the built-in permission_rule).
688
+ ask_effect(Namespace, Name, Args, Polarity, Effect) :-
689
+ length(Args, Arity),
690
+ kb:decision_predicate(Namespace, Name, Arity, _KeyPositions, DecisionPosition),
691
+ nth1(DecisionPosition, Args, Decision),
692
+ kb:effective_decision(Decision, Polarity, Effect0),
693
+ !,
694
+ Effect = Effect0.
695
+ ask_effect(_Namespace, _Name, _Args, _Polarity, null).
696
+
697
+ %% ask_schema_dict(?Namespace, +Name, -SchemaDict, -ArgumentNames)
698
+ % The project predicate_schema for the predicate, the built-in decision
699
+ % signature, or null when neither exists.
700
+ ask_schema_dict(Namespace, Name, SchemaDict, ArgumentNames) :-
701
+ kb:predicate_schema(SchemaId, Namespace, Name, Arity, ArgumentNames, ArgumentTypes),
702
+ !,
703
+ ask_schema_row(SchemaId, Namespace, Name, Arity, ArgumentNames, ArgumentTypes, SchemaDict).
704
+ ask_schema_dict(Namespace, Name, SchemaDict, ArgumentNames) :-
705
+ kb:builtin_decision_predicate(Namespace, Name, Arity, KeyPositions, DecisionPosition),
706
+ !,
707
+ ask_builtin_schema_row(Namespace, Name, Arity, KeyPositions, DecisionPosition, SchemaDict),
708
+ ArgumentNames = SchemaDict.argumentNames.
709
+ ask_schema_dict(_Namespace, _Name, null, none).
710
+
711
+ ask_builtin_schema_row(Namespace, Name, Arity, KeyPositions, DecisionPosition, Row) :-
712
+ builtin_argument_names(Name, Names),
713
+ findall(Key, (member(P, KeyPositions), nth1(P, Names, Key)), KeyArguments),
714
+ nth1(DecisionPosition, Names, DecisionArgument),
715
+ aggregate_all(count, kb:predicate_fact(_, Namespace, Name, _, _), FactCount),
716
+ Row = _{
717
+ id: null,
718
+ builtin: true,
719
+ namespace: Namespace,
720
+ name: Name,
721
+ arity: Arity,
722
+ argumentNames: Names,
723
+ argumentTypes: Names,
724
+ keyArguments: KeyArguments,
725
+ decisionArgument: DecisionArgument,
726
+ argumentConstants: null,
727
+ examples: [],
728
+ factCount: FactCount
729
+ }.
730
+
731
+ % Keep in step with builtin_decision_predicate/5 in kb.pl.
732
+ builtin_argument_names(permission_rule, [actor, action, resource, decision]).
733
+
734
+ ask_schema_row(SchemaId, Namespace, Name, Arity, ArgumentNames, ArgumentTypes, Row) :-
735
+ kb_entity(SchemaId, fact, Props),
736
+ ( memberchk(key_arguments=KeysRaw, Props)
737
+ -> kb:normalize_term_atom_list(KeysRaw, KeyArguments)
738
+ ; KeyArguments = []
739
+ ),
740
+ ( kb:decision_predicate(Namespace, Name, Arity, _, DecisionPosition),
741
+ nth1(DecisionPosition, ArgumentNames, DecisionArgument0)
742
+ -> DecisionArgument = DecisionArgument0
743
+ ; DecisionArgument = null
744
+ ),
745
+ ( memberchk(argument_constants=ConstantsRaw, Props),
746
+ ask_json_value(ConstantsRaw, Constants0)
747
+ -> Constants = Constants0
748
+ ; Constants = null
749
+ ),
750
+ ( memberchk(examples=ExamplesRaw, Props), is_list(ExamplesRaw)
751
+ -> maplist(source_value_atom, ExamplesRaw, Examples)
752
+ ; Examples = []
753
+ ),
754
+ aggregate_all(count, kb:predicate_fact(_, Namespace, Name, _, _), FactCount),
755
+ Row = _{
756
+ id: SchemaId,
757
+ builtin: false,
758
+ namespace: Namespace,
759
+ name: Name,
760
+ arity: Arity,
761
+ argumentNames: ArgumentNames,
762
+ argumentTypes: ArgumentTypes,
763
+ keyArguments: KeyArguments,
764
+ decisionArgument: DecisionArgument,
765
+ argumentConstants: Constants,
766
+ examples: Examples,
767
+ factCount: FactCount
768
+ }.
769
+
770
+ ask_json_value(^^(Value, _Type), Parsed) :- !, ask_json_value(Value, Parsed).
771
+ ask_json_value(literal(Value), Parsed) :- !, ask_json_value(Value, Parsed).
772
+ ask_json_value(Dict, Dict) :- is_dict(Dict), !.
773
+ ask_json_value(Raw, Parsed) :-
774
+ (atom(Raw) ; string(Raw)),
775
+ catch(atom_json_dict(Raw, Parsed, [value_string_as(string)]), _, fail).
776
+
777
+ %% ask_distinct(+DistinctArg, +ArgumentNames, +Rows, -Distinct, -Warnings)
778
+ ask_distinct(none, _ArgumentNames, _Rows, null, []) :- !.
779
+ ask_distinct(DistinctArg, ArgumentNames, Rows, Distinct, []) :-
780
+ is_list(ArgumentNames),
781
+ nth1(Position, ArgumentNames, DistinctArg),
782
+ !,
783
+ findall(Value, (member(Row, Rows), nth1(Position, Row.args, Value)), Values0),
784
+ msort(Values0, Values),
785
+ clumped(Values, Clumps),
786
+ findall(_{value: Value, count: Count}, member(Value-Count, Clumps), Entries),
787
+ Distinct = _{argument: DistinctArg, values: Entries}.
788
+ ask_distinct(DistinctArg, _ArgumentNames, _Rows, null, [Warning]) :-
789
+ format(atom(Warning), "distinct argument '~w' is not a declared argument of this predicate; no schema names it", [DistinctArg]).
790
+
791
+ %% ask_property_json(+SubjectKey, +PropertyKey, +Scope, +Limit, +Offset, -JsonString)
792
+ % Typed query over strict property_value facts of one subject key.
793
+ % PropertyKey `none` matches every property of the subject.
794
+ % implements REQ-kibi-query-fact-filters
795
+ ask_property_json(SubjectKey, PropertyKey, Scope, Limit, Offset, JsonString) :-
796
+ kb:normalize_term_atom(SubjectKey, Subject),
797
+ ask_namespace(PropertyKey, PropertyMatch),
798
+ findall(Row, ask_property_row(Subject, PropertyMatch, Scope, Row), Rows0),
799
+ sort_dict_rows(Rows0, SortedRows),
800
+ length(SortedRows, Count),
801
+ paginate_rows(SortedRows, Offset, Limit, Rows),
802
+ findall(SubjectId, kb:fact_subject_key(SubjectId, Subject), SubjectIds0),
803
+ sort(SubjectIds0, SubjectIds),
804
+ ask_optional_atom(PropertyKey, PropertyJson),
805
+ status_meta_dict(Meta),
806
+ Response = _{
807
+ mode: property,
808
+ property: _{subjectKey: Subject, propertyKey: PropertyJson},
809
+ subjectFacts: SubjectIds,
810
+ count: Count,
811
+ rows: Rows,
812
+ warnings: [],
813
+ meta: Meta
814
+ },
815
+ dict_json_string(Response, JsonString).
816
+
817
+ ask_property_row(Subject, PropertyMatch, Scope, Row) :-
818
+ kb:fact_property_tuple(FactId, Subject, Property, Op, ValType, Value, Unit, PropScope, Polarity),
819
+ ( PropertyMatch == Property -> true ; var(PropertyMatch) ),
820
+ ask_fact_requirements(FactId, requires_property, Requirements, HasCurrent),
821
+ ask_scope_match(Scope, HasCurrent),
822
+ ask_fact_text(FactId, Title, Status, ClaimText, CanonicalKey),
823
+ ask_property_value_json(Value, ValueJson),
824
+ Row = _{
825
+ id: FactId,
826
+ type: fact,
827
+ title: Title,
828
+ status: Status,
829
+ subjectKey: Subject,
830
+ propertyKey: Property,
831
+ operator: Op,
832
+ valueType: ValType,
833
+ value: ValueJson,
834
+ unit: Unit,
835
+ scope: PropScope,
836
+ polarity: Polarity,
837
+ canonicalKey: CanonicalKey,
838
+ claimText: ClaimText,
839
+ requirements: Requirements
840
+ }.
841
+
842
+ ask_property_value_json(Value, Value) :- number(Value), !.
843
+ ask_property_value_json(Value, Json) :- source_value_atom(Value, Json).
844
+
845
+ %% ask_schemas_json(+Namespace, +Name, +Limit, +Offset, -JsonString)
846
+ % Catalog of the predicates an agent can ask about: every project
847
+ % predicate_schema fact plus built-in decision predicates without a project
848
+ % schema, with argument names, constants, key arguments and fact counts.
849
+ % implements REQ-kibi-query-fact-filters
850
+ ask_schemas_json(Namespace, Name, Limit, Offset, JsonString) :-
851
+ ask_namespace(Namespace, NamespaceMatch),
852
+ ask_namespace(Name, NameMatch),
853
+ findall(Row,
854
+ ( kb:predicate_schema(SchemaId, NamespaceMatch, NameMatch, Arity, ArgumentNames, ArgumentTypes),
855
+ ask_schema_row(SchemaId, NamespaceMatch, NameMatch, Arity, ArgumentNames, ArgumentTypes, Row)
856
+ ),
857
+ SchemaRows),
858
+ findall(Row,
859
+ ( kb:builtin_decision_predicate(NamespaceMatch, NameMatch, Arity, KeyPositions, DecisionPosition),
860
+ \+ kb:predicate_schema(_, NamespaceMatch, NameMatch, Arity, _, _),
861
+ ask_builtin_schema_row(NamespaceMatch, NameMatch, Arity, KeyPositions, DecisionPosition, Row)
862
+ ),
863
+ BuiltinRows),
864
+ append(SchemaRows, BuiltinRows, Rows0),
865
+ map_list_to_pairs(ask_schema_sort_key, Rows0, Pairs),
866
+ keysort(Pairs, SortedPairs),
867
+ pairs_values(SortedPairs, SortedRows),
868
+ length(SortedRows, Count),
869
+ paginate_rows(SortedRows, Offset, Limit, Rows),
870
+ status_meta_dict(Meta),
871
+ Response = _{mode: schemas, count: Count, rows: Rows, warnings: [], meta: Meta},
872
+ dict_json_string(Response, JsonString).
873
+
874
+ ask_schema_sort_key(Row, Key) :-
875
+ format(atom(Key), '~w:~w/~w', [Row.namespace, Row.name, Row.arity]).
876
+
877
+ %% ask_fact_kind_json(+Kind, +Limit, +Offset, -JsonString)
878
+ % Facts of one fact_kind (subject, property_value, predicate, observation,
879
+ % meta, rule, rule_schema) with their identifying typed fields only, so a
880
+ % caller can see what is modeled without paging full entities.
881
+ % implements REQ-kibi-query-fact-filters
882
+ ask_fact_kind_json(Kind, Limit, Offset, JsonString) :-
883
+ findall(Row, ask_fact_kind_row(Kind, Row), Rows0),
884
+ sort_dict_rows(Rows0, SortedRows),
885
+ length(SortedRows, Count),
886
+ paginate_rows(SortedRows, Offset, Limit, Rows),
887
+ status_meta_dict(Meta),
888
+ Response = _{mode: fact_kind, factKind: Kind, count: Count, rows: Rows, warnings: [], meta: Meta},
889
+ dict_json_string(Response, JsonString).
890
+
891
+ ask_fact_kind_row(Kind, Row) :-
892
+ kb_entity(FactId, fact, Props),
893
+ memberchk(fact_kind=KindRaw, Props),
894
+ kb:normalize_term_atom(KindRaw, Kind),
895
+ ask_fact_text(FactId, Title, Status, ClaimText, CanonicalKey),
896
+ findall(Key-Value,
897
+ ( member(Key, [subject_key, property_key, operator, value_type, unit, predicate_namespace, predicate_name, predicate_args, polarity]),
898
+ memberchk(Key=Raw, Props),
899
+ ask_typed_value(Raw, Value)
900
+ ),
901
+ Typed),
902
+ ( Kind == property_value
903
+ -> ( memberchk(value_type=ValTypeRaw, Props)
904
+ -> kb:normalize_term_atom(ValTypeRaw, ValType)
905
+ ; ValType = string
906
+ ),
907
+ kb:value_from_props(Props, ValType, Value0),
908
+ ask_property_value_json(Value0, PropertyValue),
909
+ ValuePairs = [value-PropertyValue]
910
+ ; ValuePairs = []
911
+ ),
912
+ append(Typed, ValuePairs, Fields),
913
+ dict_pairs(Row, _, [id-FactId, type-fact, title-Title, status-Status, factKind-Kind, canonicalKey-CanonicalKey, claimText-ClaimText | Fields]).
914
+
915
+ ask_typed_value(Raw, Value) :-
916
+ is_list(Raw),
917
+ !,
918
+ kb:normalize_term_atom_list(Raw, Value).
919
+ ask_typed_value(Raw, Value) :-
920
+ source_value_atom(Raw, Value).
921
+
474
922
  matching_gap_row(TypeFilter, MissingRelationships, PresentRelationships, Tags, SourceFilter, Row) :-
475
923
  matching_gap_candidate(TypeFilter, MissingRelationships, PresentRelationships, Tags, SourceFilter, Row, Stub),
476
924
  gap_row_visible(Tags, Stub).
package/src/kb.pl CHANGED
@@ -135,6 +135,7 @@ kb_uri('urn-kibi:').
135
135
  % the memo never serves a property list the store no longer holds.
136
136
  :- dynamic kb_entity_props_memo/2.
137
137
  :- dynamic kb_entity_props_memo_key/1.
138
+ :- dynamic kb_decision_signature_memo/2.
138
139
  :- dynamic kb_index_verified_key/1.
139
140
  :- dynamic entity/4. % Support legacy .pl file format (Type, Id, Title, Props)
140
141
 
@@ -361,6 +362,7 @@ kb_detach :-
361
362
  retractall(kb_index_verified_key(_)),
362
363
  retractall(kb_entity_props_memo(_, _)),
363
364
  retractall(kb_entity_props_memo_key(_)),
365
+ retractall(kb_decision_signature_memo(_, _)),
364
366
  retractall(kb_index_entity(_, _)),
365
367
  retractall(kb_index_type(_, _)),
366
368
  retractall(kb_index_tag(_, _)),
@@ -2662,6 +2664,154 @@ req_conflict_witness(ReqA, ReqB, Witness) :-
2662
2664
  right: Right
2663
2665
  }.
2664
2666
 
2667
+ % implements REQ-kibi-truthful-consistency
2668
+ % Opposite decisions on the same case: two asserted facts of a predicate
2669
+ % whose key arguments determine a closed allow/deny decision argument (the
2670
+ % built-in permission_rule, or a project schema that declares key_arguments
2671
+ % and a `decision` argument) agree on every key argument but decide
2672
+ % differently, e.g. permission_rule(coach, read, x, allow) against
2673
+ % permission_rule(coach, read, x, deny). kb_model writes a denial as
2674
+ % decision deny with polarity deny, so a deny decision counts as a denial
2675
+ % whatever its polarity; an allow decision counts only when asserted.
2676
+ %
2677
+ % Cost: the search answer layer and coverage ask for the witnesses of each
2678
+ % requirement with the other side unbound. The pairing therefore starts
2679
+ % from whichever requirement is bound, filters its facts against the
2680
+ % decision signatures (memoized per KB generation, so the project schemas
2681
+ % are read once rather than once per fact), and pre-binds the partner's key
2682
+ % arguments before enumerating partner requirements.
2683
+ req_conflict_witness(ReqA, ReqB, Witness) :-
2684
+ decision_signatures(Signatures),
2685
+ Signatures \== [],
2686
+ decision_conflict_pair(Signatures, ReqA, ReqB, FactA, FactB, Namespace, Name,
2687
+ ArgsA, ArgsB, PolarityA, PolarityB, KeyPositions, DecisionPosition),
2688
+ length(ArgsA, Arity),
2689
+ nth1(DecisionPosition, ArgsA, DecisionA),
2690
+ nth1(DecisionPosition, ArgsB, DecisionB),
2691
+ DecisionA \== DecisionB,
2692
+ effective_decision(DecisionA, PolarityA, EffectA),
2693
+ effective_decision(DecisionB, PolarityB, EffectB),
2694
+ EffectA \== EffectB,
2695
+ findall(KeyValue, (member(Position, KeyPositions), nth1(Position, ArgsA, KeyValue)), KeyValues),
2696
+ atomic_list_concat(KeyValues, ',', KeyText),
2697
+ format(
2698
+ string(Reason),
2699
+ "Decision conflict on ~w:~w(~w): ~w decides ~w while ~w decides ~w",
2700
+ [Namespace, Name, KeyText, ReqA, EffectA, ReqB, EffectB]
2701
+ ),
2702
+ predicate_conflict_side(ReqA, FactA, Namespace, Name, ArgsA, PolarityA, Left),
2703
+ predicate_conflict_side(ReqB, FactB, Namespace, Name, ArgsB, PolarityB, Right),
2704
+ findall(SchemaId, predicate_schema(SchemaId, Namespace, Name, Arity, _Names, _Types), SchemaIds0),
2705
+ sort(SchemaIds0, SchemaIds),
2706
+ Witness = _{
2707
+ kind: predicate,
2708
+ status: contradiction,
2709
+ requirements: [ReqA, ReqB],
2710
+ reason: Reason,
2711
+ predicateNamespace: Namespace,
2712
+ predicateName: Name,
2713
+ predicateArgs: ArgsA,
2714
+ keyArguments: KeyValues,
2715
+ schemaIds: SchemaIds,
2716
+ left: Left,
2717
+ right: Right
2718
+ }.
2719
+
2720
+ %% decision_conflict_pair(+Signatures, ?ReqA, ?ReqB, -FactA, -FactB, -Namespace, -Name, -ArgsA, -ArgsB, -PolarityA, -PolarityB, -KeyPositions, -DecisionPosition)
2721
+ % Two decision-predicate facts of current requirements ReqA @< ReqB that
2722
+ % agree on every key argument. Starts from the bound requirement.
2723
+ decision_conflict_pair(Signatures, ReqA, ReqB, FactA, FactB, Namespace, Name,
2724
+ ArgsA, ArgsB, PolarityA, PolarityB, KeyPositions, DecisionPosition) :-
2725
+ ( var(ReqA), nonvar(ReqB)
2726
+ -> decision_req_fact(Signatures, ReqB, FactB, Namespace, Name, ArgsB, PolarityB, KeyPositions, DecisionPosition),
2727
+ length(ArgsB, Arity),
2728
+ length(ArgsA, Arity),
2729
+ bind_key_arguments(KeyPositions, ArgsB, ArgsA),
2730
+ current_req(ReqA),
2731
+ ReqA @< ReqB,
2732
+ effective_req_predicate(ReqA, FactA, Namespace, Name, ArgsA, PolarityA)
2733
+ ; decision_req_fact(Signatures, ReqA, FactA, Namespace, Name, ArgsA, PolarityA, KeyPositions, DecisionPosition),
2734
+ length(ArgsA, Arity),
2735
+ length(ArgsB, Arity),
2736
+ bind_key_arguments(KeyPositions, ArgsA, ArgsB),
2737
+ current_req(ReqB),
2738
+ ReqA @< ReqB,
2739
+ effective_req_predicate(ReqB, FactB, Namespace, Name, ArgsB, PolarityB)
2740
+ ),
2741
+ FactA \= FactB.
2742
+
2743
+ decision_req_fact(Signatures, ReqId, FactId, Namespace, Name, Args, Polarity, KeyPositions, DecisionPosition) :-
2744
+ current_req(ReqId),
2745
+ effective_req_predicate(ReqId, FactId, Namespace, Name, Args, Polarity),
2746
+ length(Args, Arity),
2747
+ memberchk(decision_signature(Namespace, Name, Arity, KeyPositions, DecisionPosition), Signatures).
2748
+
2749
+ %% decision_predicate(?Namespace, ?Name, ?Arity, -KeyPositions, -DecisionPosition)
2750
+ % A predicate whose key arguments (1-based KeyPositions) determine one
2751
+ % allow/deny decision argument. A project-local predicate_schema decides
2752
+ % this for its own signature: it must declare key_arguments and name a
2753
+ % non-key argument `decision`. Without a project schema the built-in
2754
+ % catalog entry applies.
2755
+ decision_predicate(Namespace, Name, Arity, KeyPositions, DecisionPosition) :-
2756
+ decision_signatures(Signatures),
2757
+ member(decision_signature(Namespace, Name, Arity, KeyPositions, DecisionPosition), Signatures).
2758
+
2759
+ %% decision_signatures(-Signatures)
2760
+ % All decision predicates of the current KB, memoized per graph, RDF
2761
+ % generation and legacy fact count like the entity property memo.
2762
+ decision_signatures(Signatures) :-
2763
+ index_verification_key(decision_signatures, Key),
2764
+ ( kb_decision_signature_memo(Key, Cached)
2765
+ -> Signatures = Cached
2766
+ ; compute_decision_signatures(Computed),
2767
+ retractall(kb_decision_signature_memo(_, _)),
2768
+ assertz(kb_decision_signature_memo(Key, Computed)),
2769
+ Signatures = Computed
2770
+ ).
2771
+
2772
+ compute_decision_signatures(Signatures) :-
2773
+ findall(Namespace-Name-Arity,
2774
+ predicate_schema(_SchemaId, Namespace, Name, Arity, _Names, _Types),
2775
+ SchemaSignatures0),
2776
+ sort(SchemaSignatures0, SchemaSignatures),
2777
+ findall(decision_signature(Namespace, Name, Arity, KeyPositions, DecisionPosition),
2778
+ ( member(Namespace-Name-Arity, SchemaSignatures),
2779
+ schema_decision_positions(Namespace, Name, Arity, KeyPositions, DecisionPosition)
2780
+ ),
2781
+ FromSchemas),
2782
+ findall(decision_signature(Namespace, Name, Arity, KeyPositions, DecisionPosition),
2783
+ ( builtin_decision_predicate(Namespace, Name, Arity, KeyPositions, DecisionPosition),
2784
+ \+ memberchk(Namespace-Name-Arity, SchemaSignatures)
2785
+ ),
2786
+ Builtins),
2787
+ append(FromSchemas, Builtins, Signatures).
2788
+
2789
+ schema_decision_positions(Namespace, Name, Arity, KeyPositions, DecisionPosition) :-
2790
+ predicate_schema_keys(Namespace, Name, Arity, KeyPositions),
2791
+ predicate_schema(_SchemaId, Namespace, Name, Arity, ArgumentNames, _ArgumentTypes),
2792
+ nth1(DecisionPosition, ArgumentNames, decision),
2793
+ \+ memberchk(DecisionPosition, KeyPositions),
2794
+ !.
2795
+
2796
+ %% bind_key_arguments(+KeyPositions, +ArgsA, ?ArgsB)
2797
+ % Unify ArgsB with ArgsA on every key position.
2798
+ bind_key_arguments([], _ArgsA, _ArgsB).
2799
+ bind_key_arguments([Position|Positions], ArgsA, ArgsB) :-
2800
+ nth1(Position, ArgsA, Value),
2801
+ nth1(Position, ArgsB, Value),
2802
+ bind_key_arguments(Positions, ArgsA, ArgsB).
2803
+
2804
+ %% builtin_decision_predicate(?Namespace, ?Name, ?Arity, ?KeyPositions, ?DecisionPosition)
2805
+ % Built-in catalog predicates with a closed allow/deny decision. Keep in
2806
+ % step with key_arguments in packages/cli/src/operations/modeling/
2807
+ % predicate-catalog-4.ts (permission_rule: actor, action, resource).
2808
+ builtin_decision_predicate(default, permission_rule, 4, [1, 2, 3], 4).
2809
+
2810
+ %% effective_decision(+Decision, +Polarity, -Effect)
2811
+ effective_decision(allow, assert, allow).
2812
+ effective_decision(allow, deny, deny).
2813
+ effective_decision(deny, _Polarity, deny).
2814
+
2665
2815
  property_conflict_side(ReqId, FactId, SubjectKey, PropertyKey, Operator, ValueType, Value, Unit, Scope, Polarity, ValidFrom, ValidTo, Side) :-
2666
2816
  entity_evidence_source(ReqId, req, RequirementSource),
2667
2817
  fact_evidence_fields(FactId, FactSource, ClaimKey, ClaimText, _CanonicalKey),