@factoidal/core 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0
4
+
5
+ **Checked pipelines.** Each component (reader, store, query evaluator, writer, reasoner, validator, proof checker) has one interface type whose contract against the W3C specifications is part of the type. A component cannot be added without a contract; a contract that is not proved yet is a named assumption, listed and counted in CI. A theorem states that the SPARQL answer agrees with the SPARQL 1.1 semantics for data from any source (a parsed document, a Dataset after edits, an update, a reasoner's output, a union of stores), given the listed assumptions. Status and gaps: https://github.com/danbri/factoidal/issues/975.
6
+
7
+ - `explain(sources, description, { profile: "rdfs" })`: whether the sources RDFS-entail a description graph (its blank nodes are variables), with a checked derivation (`bundle`) when it does and the reason (`rejection`) when not (https://github.com/danbri/factoidal/issues/1002).
8
+ - `run(pipeline, input, { unchecked: true })`: runs nodes whose stage is not proved; each evidence record has `checked`, and the result has `proved` and `uncheckedSteps`. Without the option a stage with no contract does not run.
9
+ - `Dataset.warnings`: a language tag that is not well-formed BCP 47 is kept and reported with its line, column and offset; the writers write it unchanged (https://github.com/danbri/factoidal/issues/1004).
10
+ - SPARQL `SELECT *` on the store path returns the variables in query order, the same header as the in-memory path (https://github.com/danbri/factoidal/issues/1014).
11
+ - SBM11 in npm (`openSbm11`) is **not** in this release. Its wasm op `sbm11Open` traps (`unreachable`) in the release build, so the ops are not dispatched and the functions are not exported; the native `l4sbm11` commands are unchanged (https://github.com/danbri/factoidal/issues/1009).
12
+ - `factoidal pack`, `factoidal sample-store` and `sampleStorePath()` write the IBK5 layout (wire version 10, manifest layout `quad-ibk5-ptd2-lgi4-gbi1-merkle-v0`) when no `--layout` is given; the default was IBK3. Owner, 2026-10-25: "yes IBK5 not IBK3 as default sounds sensible, assuming no major downside" (https://github.com/danbri/factoidal/issues/1009). `factoidal serve` over such a store takes changes to every graph; over an IBK3 store a change to a named graph answered 501. `--layout ibk3` still writes IBK3. `sampleStoreFacts.layout` is now `'ibk5'`.
13
+ - `stages()`, `node()`, `pipe()`, `run()`: the registered Lean stages as nodes (https://github.com/danbri/factoidal/issues/1002, step 2). `stages()` is the engine's op `stageList`, read from the Lean stage registry: name, interface type, wire types, status, contract and named assumptions of each stage. A value on a wire is `{graph, evidence}`; each node adds one evidence record `{stage, status, contract, assumptions, artefact}`, and `run()` reports the union of the named assumptions of the nodes. A stage with status `missing` does not run. Needs a wasm build with the op `stageList`.
14
+ - `factoidal pack` writes a set of rows in every layout (https://github.com/danbri/factoidal/issues/1006). Correction to 0.13.0: its entry says that the stores keep one copy of a repeated triple, but in 0.13.0 that was true for the IBK4 and IBK5 layouts only, and only on read. The default layout (IBK3) kept a repeated triple, and kept `"a"@en` and `"a"@EN` as two rows, so `COUNT(*)` counted both; the IBK4 and IBK5 packers also wrote the repeated rows. Now each packer keeps the distinct triples it has written and writes a triple only once (per graph on the quad layouts). A source with no repeated triple gives the same generation as before. The packer's memory now grows with the number of distinct triples of the source. Proved: `Dataflow.packedRows_keyNodup`; tests: `test/pack-is-a-set-1006.test.mjs`.
15
+ - The N-Triples, N-Quads, Turtle and TriG writers give a fresh valid label to a blank-node label outside BLANK_NODE_LABEL (the same label for each occurrence, never a label of another blank node), and refuse a language tag outside LANGTAG with an error that names the tag (https://github.com/danbri/factoidal/issues/986).
16
+ - A language tag is kept in lower case (RDF 1.2 Concepts §3.4: `"x"@en` and `"x"@EN` are one RDF term). Every reader, `STRLANG`, `STRLANGDIR` and the term constructors write the tag in lower case, so `LANG()` returns it in lower case, and a SPARQL answer no longer depends on the order of the triple patterns when the data spells one tag in two ways (https://github.com/danbri/factoidal/issues/1001).
17
+
3
18
  ## 0.13.0
4
19
 
5
20
  **A parsed document is a set of quads** (RDF 1.1 Concepts §3 and §4;
package/README.md CHANGED
@@ -52,6 +52,50 @@ RDF 1.2 input uses `turtle12`, `ntriples12`, `nquads12` or `trig12`.
52
52
  "Terms given as strings".
53
53
  - RDF/JS DataFactory and DatasetCore (`/rdfjs`), a functional API (`/fn`)
54
54
 
55
+ ## Checked pipelines
56
+
57
+ Each component kind has one interface type whose contract against the W3C
58
+ specifications is part of the type: readers, stores, the SPARQL evaluator,
59
+ writers, reasoners, validators and the proof checker. A contract that is not
60
+ proved yet is a named assumption; CI fails when the list of assumptions grows
61
+ without a stated reason. A theorem states that the SPARQL answer agrees with
62
+ the SPARQL 1.1 semantics for data from any source (a parsed document, an
63
+ edited Dataset, an update, a reasoner's output, a union of stores), given the
64
+ listed assumptions. No new command-line verbs. Status:
65
+ https://github.com/danbri/factoidal/issues/975.
66
+
67
+ The same components are nodes you can compose. A value on a wire is
68
+ `{graph, evidence}`; each node adds one evidence record; the result lists the
69
+ union of the named assumptions of the nodes, so a pipeline says exactly what
70
+ its answer depends on.
71
+
72
+ ```js
73
+ import { stages, node, pipe, run } from "@factoidal/core";
74
+
75
+ const sources = `@prefix ex: <http://example.org/> .
76
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
77
+ ex:alice ex:worksFor ex:acme .
78
+ ex:worksFor rdfs:domain ex:Employee .
79
+ ex:Employee rdfs:subClassOf ex:Person .`;
80
+ const description = `@prefix ex: <http://example.org/> .
81
+ _:x a ex:Person . _:x ex:worksFor ex:acme .`;
82
+
83
+ console.log((await stages()).length); // registered components, each with status and contract
84
+ const p = pipe(
85
+ await node("turtleDocumentReader"),
86
+ await node("rdfsReasoner"),
87
+ await node("rdfsExplainGraphStage", { description }),
88
+ );
89
+ const r = await run(p, sources);
90
+ console.log(r.evidence.map((e) => e.stage)); // one record per node
91
+ console.log(r.evidence[2].artefact.entailed); // true, with a checked derivation
92
+ console.log(r.assumptions, r.proved); // what the answer depends on
93
+ ```
94
+
95
+ `run(p, input, { unchecked: true })` also runs components that are not proved,
96
+ and marks those steps in the evidence. Details: API.md, "Stages as nodes" and
97
+ "Explaining an entailment" (`explain()` is the same check as one call).
98
+
55
99
  Formats (generated from `formats()`):
56
100
 
57
101
  <!-- formats-table:start (generated by test/formats-table.test.mjs) -->
@@ -135,7 +179,7 @@ Changes: [CHANGELOG.md](CHANGELOG.md).
135
179
  ```console
136
180
  $ npx factoidal serve --file data.ttl /ds # SPARQL endpoint at /ds/sparql
137
181
  $ npx factoidal serve --tdb2 DIR /ds # serve a Jena TDB2 database
138
- $ npx factoidal pack data.ttl store/gen-1 && npx factoidal activate store gen-1
182
+ $ npx factoidal pack data.ttl store/gen-1 && npx factoidal activate store gen-1 # IBK5 quad blocks
139
183
  $ npx factoidal query store 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'
140
184
  $ npx factoidal query "$(npx factoidal sample-store)" 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
141
185
  $ npx factoidal lws-serve DIR --issuer IRI # Linked Web Storage
@@ -150,13 +194,15 @@ $ npx factoidal solid-serve DIR # Solid Protocol
150
194
  Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
151
195
 
152
196
  The OWL DL rows below are historical measurements of the legacy harness;
153
- they do not measure the current `owlIsConsistent` API. That API now checks
154
- only an admitted atomic fragment of the default graph. It returns `false`
155
- for a checked contradiction and `null` for unsupported input or an incomplete
156
- check; it never returns `true`. Restrictions, negative property assertions,
157
- imports and equivalentClass are unsupported, and `fuel` does not change its
158
- fixed 64-round checker bound. `owlEntails` still uses a separate legacy path
159
- whose positive verdicts do not have this source-to-verdict proof. See the
197
+ they do not measure the current `owlIsConsistent` API. That API checks only
198
+ an admitted fragment of the default graph (atomic classes,
199
+ `owl:someValuesFrom`, `owl:TransitiveProperty`, and annotations, axiom
200
+ annotations included). It returns `true` for a checked finite model, `false`
201
+ for a checked contradiction and `null` for unsupported input, exhausted
202
+ `fuel` or an incomplete check. Other restrictions, negative property
203
+ assertions, imports and equivalentClass are unsupported. `owlEntails`
204
+ answers `false` only with a checked countermodel; its positive verdicts use a
205
+ separate legacy path that does not have this source-to-verdict proof. See the
160
206
  [OWL API contract](https://github.com/danbri/factoidal-core/blob/main/npm/factoidal/API.md)
161
207
  and the [assurance record](https://github.com/danbri/factoidal-core/blob/main/docs/20261022-owl-pr-reconciliation.md).
162
208
 
package/bin/factoidal.mjs CHANGED
@@ -224,9 +224,9 @@ INPUT is an RDF file. OUTPUT is the generation directory to create; it is
224
224
  normally STORE/gen-N. Packing does not activate: run activate next.
225
225
 
226
226
  options:
227
- --layout LAYOUT ibk3 (triples, default), ibk4 (quads, wire version 9)
228
- or ibk5 (quads, wire version 10: RDF 1.2 terms,
229
- out-of-line literals, zone maps)
227
+ --layout LAYOUT ibk5 (quads, wire version 10: RDF 1.2 terms,
228
+ out-of-line literals, zone maps; default), ibk4
229
+ (quads, wire version 9) or ibk3 (triples)
230
230
  --syntax SYNTAX turtle, trig or nquads; default from the file extension
231
231
  --chunk-bytes N Merkle chunk size; default is the engine's
232
232
  --batch-bytes N ibk4 only: source bytes between publication batches;
@@ -319,7 +319,7 @@ activated generation's delta log as one batch before the response is sent,
319
319
  and is visible to the next request. An IBK3 (triples) generation accepts
320
320
  changes to the default graph only, because its delta log is replayed and
321
321
  compacted for that graph only; a change to a named graph answers 501. A
322
- quad generation (ibk4, ibk5) accepts every graph. Fold the log into a new
322
+ quad generation (ibk4, ibk5, the default) accepts every graph. Fold the log into a new
323
323
  generation with l4block-shard-compact, activate it, and restart the server.
324
324
 
325
325
  The server makes no protocol decision: the request framing, routing,
@@ -1266,7 +1266,7 @@ function currentDirectory () {
1266
1266
  }
1267
1267
 
1268
1268
  function packLayout (options) {
1269
- if (typeof options.layout !== 'string') return 'ibk3'
1269
+ if (typeof options.layout !== 'string') return 'ibk5'
1270
1270
  const layout = options.layout.toLowerCase()
1271
1271
  if (PACK_LAYOUTS.indexOf(layout) >= 0) return layout
1272
1272
  throw new UsageError(`--layout ${options.layout} is not one of ${PACK_LAYOUTS.join(', ')}`)
@@ -1528,7 +1528,7 @@ async function commandServe (positional, options) {
1528
1528
  const { tmpdir } = await import('node:os')
1529
1529
  const { join } = await import('node:path')
1530
1530
  const root = mkdtempSync(join(tmpdir(), 'factoidal-serve-'))
1531
- const packOptions = { quiet: true, layout: typeof options.layout === 'string' ? options.layout : 'ibk5' }
1531
+ const packOptions = { quiet: true, layout: packLayout(options) }
1532
1532
  let rc = await commandPack([options.file, join(root, 'gen-1')], packOptions)
1533
1533
  if (rc !== 0) return rc
1534
1534
  rc = await commandActivate([root, 'gen-1'], { quiet: true })
package/browser.d.ts CHANGED
@@ -11,6 +11,8 @@ export type {
11
11
  DataFormat, EntailRegime, Bindings, DataInput, QueryInput, ParseOptions,
12
12
  QueryOptions, SerializeOptions, StoreReader, OpenStoreOptions,
13
13
  DeltaLogHandle, DeltaLogInspection,
14
+ Sbm11Reader, Sbm11Answer, Sbm11Store,
15
+ ExplainResult, StageInfo, EvidenceRecord, Wire, FactoidalNode, Pipeline, RunResult, RunOptions,
14
16
  } from './index';
15
17
 
16
18
  export {
@@ -50,6 +52,11 @@ export {
50
52
  owlClosure,
51
53
  coreRdfsClosure,
52
54
  coreRdfsCheck,
55
+ explain,
56
+ stages,
57
+ node,
58
+ pipe,
59
+ run,
53
60
  rdfsPlusClosure,
54
61
  rhoDfClosure,
55
62
  rhoDfFragmentCheck,
package/browser.js CHANGED
@@ -59,6 +59,9 @@ const api = buildApi({
59
59
  deltaLogStore: indexedDbDeltaLogStore(),
60
60
  });
61
61
 
62
+ // SBM11 (openSbm11, registerSbm11Source) is not exported in 0.14.0: the op
63
+ // sbm11Open traps in the wasm build (https://github.com/danbri/factoidal/issues/1009).
64
+
62
65
  // RDF and SPARQL
63
66
  export const parse = api.parse;
64
67
  export const query = api.query;
@@ -102,6 +105,11 @@ export const shexValidate = api.shexValidate;
102
105
  export const owlClosure = api.owlClosure;
103
106
  export const coreRdfsClosure = api.coreRdfsClosure;
104
107
  export const coreRdfsCheck = api.coreRdfsCheck;
108
+ export const explain = api.explain;
109
+ export const stages = api.stages;
110
+ export const node = api.node;
111
+ export const pipe = api.pipe;
112
+ export const run = api.run;
105
113
  export const rdfsPlusClosure = api.rdfsPlusClosure;
106
114
  export const rhoDfClosure = api.rhoDfClosure;
107
115
  export const rhoDfFragmentCheck = api.rhoDfFragmentCheck;
package/index.d.ts CHANGED
@@ -184,7 +184,8 @@ export interface ParseDiagnostic {
184
184
  * 'unterminated-string', 'bad-literal', 'bad-blank-node',
185
185
  * 'bad-prefixed-name', 'bad-directive', 'bad-graph-name', 'rdf12-syntax',
186
186
  * 'unclosed-group', 'nesting-limit', 'unexpected-token', 'internal' or
187
- * 'too-many-errors'.
187
+ * 'too-many-errors'. A warning (`Dataset.warnings`) has the class
188
+ * 'ill-formed-language-tag'.
188
189
  */
189
190
  class: string;
190
191
  }
@@ -250,6 +251,13 @@ export class Dataset implements Iterable<Quad> {
250
251
  readonly prefixes: Record<string, string>;
251
252
  /** Diagnostics of a `{lenient:true}` parse; else empty. */
252
253
  readonly diagnostics: ParseDiagnostic[];
254
+ /**
255
+ * Warnings of the parse of text: one per language tag that is not
256
+ * well-formed under BCP 47 section 2.2.9 (class 'ill-formed-language-tag',
257
+ * the position of the tag's first character). The reader keeps the triple
258
+ * and the writers write the tag unchanged. Empty otherwise.
259
+ */
260
+ readonly warnings: ParseDiagnostic[];
253
261
  /** The parse format tag, when the data was read from text. */
254
262
  readonly format: string | undefined;
255
263
  /** True when `format` was inferred because the caller gave none. */
@@ -627,6 +635,159 @@ export function coreRdfsCheck(
627
635
  options?: { format?: DataFormat; baseIRI?: string }
628
636
  ): Promise<{ ok: boolean; fragment: boolean }>;
629
637
 
638
+ /** The evidence bundle and verdict of explain(). */
639
+ export interface ExplainResult {
640
+ profile: 'rdfs';
641
+ /** true: the checker accepted `bundle`; the sources RDFS-entail the description. */
642
+ entailed: boolean;
643
+ bundle: null | {
644
+ sources: string;
645
+ description: string;
646
+ mapping: Array<{ bnode: string; term: string }>;
647
+ steps: Array<{ rule: string; conclusion: string; premises: number[] }>;
648
+ support: number[];
649
+ };
650
+ /** Named assumptions that `entailed: false` relies on. */
651
+ assumptions: string[];
652
+ rejection:
653
+ | null
654
+ | { kind: 'noMatch'; index: number; triple: string }
655
+ | { kind: 'noJointMapping' }
656
+ | { kind: 'checkerRefused'; reason: string };
657
+ entailedMeans: string;
658
+ }
659
+
660
+ /**
661
+ * Is `description` (blank nodes shared across its triples) RDFS-entailed by
662
+ * `sources`? Full engine only; the description is default-graph only. Text
663
+ * inputs are Turtle unless `format` says otherwise.
664
+ * https://github.com/danbri/factoidal/issues/1002
665
+ */
666
+ export function explain(
667
+ sources: DataInput,
668
+ description: DataInput,
669
+ options?: {
670
+ profile?: 'rdfs';
671
+ format?: DataFormat;
672
+ baseIRI?: string;
673
+ sourcesFormat?: DataFormat;
674
+ descriptionFormat?: DataFormat;
675
+ }
676
+ ): Promise<ExplainResult>;
677
+
678
+ /**
679
+ * One registered stage, as the engine's op stageList reads it from the Lean
680
+ * registry (L4Factoidal.Dataflow.registry).
681
+ * https://github.com/danbri/factoidal/issues/1002
682
+ */
683
+ export interface StageInfo {
684
+ /** The stage declaration relative to L4Factoidal.Dataflow (or L4Factoidal); the area when there is none. */
685
+ name: string;
686
+ /** The full Lean declaration name, or null (status tested or missing). */
687
+ declaration: string | null;
688
+ area: string;
689
+ dir: string;
690
+ /** The interface type of the declaration (Stage, RdfReader, SoundReasoner, ...), or null. */
691
+ kind: string | null;
692
+ wireIn: string;
693
+ wireOut: string;
694
+ status: 'proved' | 'tested' | 'assumed' | 'missing';
695
+ /** The contract declaration (full Lean name), or null. */
696
+ contract: string | null;
697
+ theorems: string[];
698
+ /** The named assumptions of the stage (full Lean names). */
699
+ assumptions: string[];
700
+ tests: string[];
701
+ note: string;
702
+ /** false exactly when status is 'missing': such a stage does not run. */
703
+ runnable: boolean;
704
+ /** true when this package has a function that runs the stage as a node. */
705
+ bound: boolean;
706
+ }
707
+
708
+ /** One evidence record: the node that ran and what it produced. */
709
+ export interface EvidenceRecord {
710
+ stage: string;
711
+ status: StageInfo['status'];
712
+ contract: string | null;
713
+ assumptions: string[];
714
+ /** true iff `status` is 'proved'. */
715
+ checked: boolean;
716
+ /** true on a step that ran under run()'s `unchecked` opt-in and is not checked. */
717
+ unchecked?: true;
718
+ /** With `unchecked`: the status and named assumptions, or 'no contract' for a missing stage. */
719
+ reason?: string;
720
+ /** The node's own output beside the graph: the explain result, the SHACL report, the written text, ... */
721
+ artefact: unknown;
722
+ }
723
+
724
+ /** The value on a wire. */
725
+ export interface Wire<G = unknown> {
726
+ graph: G;
727
+ evidence: EvidenceRecord[];
728
+ }
729
+
730
+ /** A node: a registered stage with its parameters. */
731
+ export interface FactoidalNode {
732
+ readonly stage: string;
733
+ readonly kind: string | null;
734
+ readonly status: StageInfo['status'];
735
+ readonly contract: string | null;
736
+ readonly assumptions: readonly string[];
737
+ readonly wireIn: string;
738
+ readonly wireOut: string;
739
+ readonly runnable: boolean;
740
+ readonly bound: boolean;
741
+ readonly params: Readonly<Record<string, unknown>>;
742
+ }
743
+
744
+ /** A pipeline: nodes in order; `assumptions` is the union of theirs. */
745
+ export interface Pipeline {
746
+ readonly nodes: readonly FactoidalNode[];
747
+ readonly assumptions: readonly string[];
748
+ }
749
+
750
+ /** The result of run(): the last graph, one record per node, the assumption union. */
751
+ export interface RunResult<G = unknown> extends Wire<G> {
752
+ assumptions: string[];
753
+ /** true iff every evidence record is checked (status 'proved'). */
754
+ proved: boolean;
755
+ /** The stage names of the records that are not checked. */
756
+ uncheckedSteps: string[];
757
+ }
758
+
759
+ /** Options of run(). */
760
+ export interface RunOptions {
761
+ /**
762
+ * Let stages that are not 'proved' run, and 'missing' stages too when the
763
+ * package has a runner for them. Each such step's record says so, and the
764
+ * result's `proved` is false. A stage with no runner still rejects (op.unknown).
765
+ */
766
+ unchecked?: boolean;
767
+ }
768
+
769
+ /** The engine's stage list (op stageList, full engine). */
770
+ export function stages(): Promise<StageInfo[]>;
771
+
772
+ /**
773
+ * A node for the registered stage `name`. `params` are passed to the
774
+ * package function that runs it: reader options; `{shapes}` for
775
+ * shaclValidator; `{query}` for shippedEvaluator; `{description}` for
776
+ * rdfsExplainGraphStage. An unknown name rejects with a FactoidalError.
777
+ */
778
+ export function node(name: string, params?: Record<string, unknown>): Promise<FactoidalNode>;
779
+
780
+ /** Compose nodes and pipelines left to right. */
781
+ export function pipe(...nodes: Array<FactoidalNode | Pipeline>): Pipeline;
782
+
783
+ /**
784
+ * Run a node or pipeline on `input` (a Wire, or the graph: text for a
785
+ * reader, else a Dataset or data input). Rejects before any node runs when
786
+ * a node's stage has status 'missing' (unless `options.unchecked`) or has
787
+ * no package function.
788
+ */
789
+ export function run(pipeline: FactoidalNode | Pipeline, input: unknown, options?: RunOptions): Promise<RunResult>;
790
+
630
791
  /** Literature-name alias for coreRdfsClosure (ρdf). */
631
792
  export function rhoDfClosure(
632
793
  data: DataInput,
@@ -681,15 +842,17 @@ export function tableauDlInconsistent(
681
842
 
682
843
  /**
683
844
  * Checked consistency for the original default graph in a restricted
684
- * atomic OWL fragment (named subclass/class assertions, Thing/Nothing,
685
- * sameAs/differentFrom and limited declarations/annotations).
686
- * false requires an accepted certificate; null means unsupported-syntax
687
- * or incomplete-search. No model producer exists, so true is not returned.
688
- * Named graphs do not contribute axioms. RDF 1.2 terms are refused.
689
- * This narrows earlier unchecked coverage: restrictions, object/data
690
- * property axioms, imports and equivalentClass are unsupported.
691
- * fuel is retained for compatibility but does not change this fixed
692
- * 64-round certificate check. This is not complete OWL DL support.
845
+ * OWL fragment (named subclass/class assertions, Thing/Nothing,
846
+ * owl:someValuesFrom restrictions as a superclass or a class assertion,
847
+ * owl:TransitiveProperty, sameAs/differentFrom, declarations, annotations
848
+ * and annotated axioms).
849
+ * true requires a checked finite model and has no `reason`; false requires
850
+ * an accepted refutation certificate; null means unsupported-syntax,
851
+ * budget-out or incomplete-search. Named graphs do not contribute axioms.
852
+ * RDF 1.2 terms are refused. Other restrictions, object/data property
853
+ * axioms, imports and equivalentClass are unsupported. `fuel` bounds the
854
+ * model search and the certificate checker (default 20000 rounds).
855
+ * This is not complete OWL DL support.
693
856
  */
694
857
  export function owlIsConsistent(
695
858
  data: DataInput,
@@ -703,7 +866,9 @@ export function owlIsConsistent(
703
866
  * implementation returns `true` for those positive results and `null`
704
867
  * when it cannot establish entailment. `reason` distinguishes budget-out,
705
868
  * incomplete-search, unsupported-conclusion and unsupported-punning.
706
- * No checked countermodel constructor exists, so it never returns `false`.
869
+ * `false` (via `refutation`, reason `countermodel: ...`) requires a checked
870
+ * finite model of the premise in which the conclusion fails; both graphs
871
+ * must be in the fragment that `owlIsConsistent` reads.
707
872
  * The punning refusal is limited; full OWL DL recognition and universal
708
873
  * Direct Semantics soundness are not established by this API.
709
874
  */
@@ -897,6 +1062,42 @@ export class Store {
897
1062
  */
898
1063
  export function openStore(source: string | StoreReader, options?: OpenStoreOptions): Promise<Store>;
899
1064
 
1065
+ /** A synchronous reader of the files of one SBM11 generation directory (or
1066
+ * collection root): the engine names every file and checks every answer. */
1067
+ export interface Sbm11Reader {
1068
+ readAt(file: string, offset: number, length: number): Uint8Array | null;
1069
+ size(file: string): number | null;
1070
+ }
1071
+
1072
+ /** The answer of one SBM11 query. */
1073
+ export interface Sbm11Answer {
1074
+ /** `path=ids` or `path=reference reason="..." ...` */
1075
+ path: string;
1076
+ rows: number;
1077
+ /** `srj` (SELECT, ASK) or `ntriples` (CONSTRUCT, DESCRIBE) */
1078
+ format: 'srj' | 'ntriples';
1079
+ /** the bytes `l4sbm11 query` writes, as text */
1080
+ readonly result: string;
1081
+ /** the same answer as UTF-8 bytes */
1082
+ readonly bytes: Uint8Array;
1083
+ bytesRead: number;
1084
+ reads: number;
1085
+ /** the parsed SPARQL JSON for SELECT and ASK */
1086
+ readonly json: any;
1087
+ }
1088
+
1089
+ /** An open SBM11 generation. */
1090
+ export interface Sbm11Store {
1091
+ handle: string;
1092
+ /** the generation CURRENT names, or "" when the source is a generation */
1093
+ generation: string;
1094
+ terms: number;
1095
+ query(sparql: string, options?: { base?: string; sparql12?: boolean; reference?: boolean }): Sbm11Answer;
1096
+ close(): void;
1097
+ }
1098
+
1099
+
1100
+
900
1101
  /** One query against a store: open with the query's plan, query, close. */
901
1102
  export function queryStore(
902
1103
  source: string | StoreReader,
@@ -1302,6 +1503,8 @@ export function capabilities(): Promise<{
1302
1503
  shacl: boolean;
1303
1504
  shex: boolean;
1304
1505
  owlClosure: boolean;
1506
+ explain: boolean;
1507
+ stages: boolean;
1305
1508
  tableau: boolean;
1306
1509
  rml: boolean;
1307
1510
  csvw: boolean;
package/index.mjs CHANGED
@@ -66,6 +66,9 @@ export const clearExtensionFunctions = api.clearExtensionFunctions;
66
66
  export const registerServiceEndpoint = api.registerServiceEndpoint;
67
67
  export const clearServiceEndpoints = api.clearServiceEndpoints;
68
68
 
69
+ // SBM11 (openSbm11, registerSbm11Source) is not exported in 0.14.0: the op
70
+ // sbm11Open traps in the wasm build (https://github.com/danbri/factoidal/issues/1009).
71
+
69
72
  // The persisted store
70
73
  export const openStore = api.openStore;
71
74
  export const queryStore = api.queryStore;
@@ -86,6 +89,11 @@ export const shexValidate = api.shexValidate;
86
89
  export const owlClosure = api.owlClosure;
87
90
  export const coreRdfsClosure = api.coreRdfsClosure;
88
91
  export const coreRdfsCheck = api.coreRdfsCheck;
92
+ export const explain = api.explain;
93
+ export const stages = api.stages;
94
+ export const node = api.node;
95
+ export const pipe = api.pipe;
96
+ export const run = api.run;
89
97
  export const rdfsPlusClosure = api.rdfsPlusClosure;
90
98
  export const rhoDfClosure = api.rhoDfClosure;
91
99
  export const rhoDfFragmentCheck = api.rhoDfFragmentCheck;
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "1b55f66d8e19";
40
+ const WASM_VERSION = "2e65b7c2fda1";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -2,17 +2,17 @@
2
2
  "engine": "lean4",
3
3
  "note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
4
4
  "stamped": true,
5
- "release": "danbri/factoidal-builds build-6f5b96407a4c-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40076270080",
7
- "gitSha": "6f5b96407a4cd95ac7452256ed2d4b9c938a7c49",
8
- "builtAt": "2026-10-24T08:40:08Z",
5
+ "release": "danbri/factoidal-builds build-159a7adfbd01-web",
6
+ "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40192154339",
7
+ "gitSha": "159a7adfbd0155fc560583bc028a54f4508cd4ee",
8
+ "builtAt": "2026-10-25T12:48:55Z",
9
9
  "leanToolchain": "leanprover/lean4:v4.33.1",
10
10
  "emscripten": "6.0.8",
11
11
  "abiVersion": "1",
12
12
  "engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
13
13
  "version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
14
- "wasmSha256": "1b55f66d8e193c6a3d168838bd77e8a5b1accb94e0a7c84209b63429f4989396",
15
- "wasmBytes": 14483288,
14
+ "wasmSha256": "2e65b7c2fda13352e80f73ee224d3614551455f7361fa8b1596bb5f702653feb",
15
+ "wasmBytes": 15069305,
16
16
  "claims": {
17
17
  "source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
18
18
  "suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
package/lib/api.mjs CHANGED
@@ -29,6 +29,7 @@ import { openGeneration, readRegion, manifestKeys, hexOfBytes } from './store.mj
29
29
  import { fullEngineMessage } from './lean-entry.mjs';
30
30
  import { memoryDeltaLogStore } from './delta-log-hosts.mjs';
31
31
  import { FactoidalError, errorFromEnvelope, envelopeResult } from './errors.mjs';
32
+ import { buildNodes } from './nodes.mjs';
32
33
 
33
34
  // The format names, regimes and query outputs are the engine's tables (op
34
35
  // engineTables, formal/lean4/Wasm/Ops/Handles.lean), read once per engine:
@@ -530,7 +531,10 @@ function buildApi(driver) {
530
531
  },
531
532
  missing: (what, op) => missingOpError(what, e, op),
532
533
  wrap(r, prefixes, extra) {
533
- const { ok: _ok, handle, count, prefixes: own, ...info } = r;
534
+ // `warnings` (the reader's language-tag warnings) is not an info
535
+ // member: openText() and the lenient parse() pass the warnings of the
536
+ // caller's text in `extra`, with their offsets in UTF-16 units.
537
+ const { ok: _ok, handle, count, prefixes: own, warnings: _w, ...info } = r;
534
538
  return new Dataset(INTERNAL, c, handle, count,
535
539
  prefixes !== undefined ? prefixes : own, { ...(extra || {}), info });
536
540
  },
@@ -595,7 +599,10 @@ function buildApi(driver) {
595
599
  }
596
600
  throw err;
597
601
  }
598
- return c.wrap(r, undefined, { format: tag, formatInferred: inferred });
602
+ return c.wrap(r, undefined, {
603
+ format: tag, formatInferred: inferred,
604
+ warnings: utf16Diagnostics(text, r.warnings),
605
+ });
599
606
  }
600
607
 
601
608
  // The Dataset a consumer reads: `data` itself when it is a Dataset, else
@@ -741,6 +748,7 @@ function buildApi(driver) {
741
748
  const opened = entryResult(c.e.datasetOpen(r.nquads, 'nquads', ''), 'parse');
742
749
  return c.wrap(opened, r.prefixes, {
743
750
  diagnostics: utf16Diagnostics(text, r.diagnostics),
751
+ warnings: utf16Diagnostics(text, r.warnings),
744
752
  format: tag, formatInferred: inferred,
745
753
  });
746
754
  }
@@ -1023,6 +1031,36 @@ function buildApi(driver) {
1023
1031
  /** Literature-name alias for coreRdfsCheck. */
1024
1032
  const rhoDfFragmentCheck = coreRdfsCheck;
1025
1033
 
1034
+ /**
1035
+ * Is `description` (a graph whose blank nodes are shared variables)
1036
+ * RDFS-entailed by `sources`? Returns the evidence bundle when it is
1037
+ * (https://github.com/danbri/factoidal/issues/1002). Op explainGraph, full
1038
+ * engine only. `entailed: false` also relies on the named assumption in
1039
+ * `assumptions`. Text inputs are Turtle unless `format` says otherwise.
1040
+ * @param {Dataset|string|Array} sources
1041
+ * @param {Dataset|string|Array} description default graph only
1042
+ * @param {{profile?: 'rdfs', format?: string, baseIRI?: string,
1043
+ * sourcesFormat?: string, descriptionFormat?: string}} [options]
1044
+ * @returns {Promise<{profile: string, entailed: boolean, bundle: object|null,
1045
+ * assumptions: string[], rejection: object|null, entailedMeans: string}>}
1046
+ */
1047
+ async function explain(sources, description, options) {
1048
+ const o = optionsOf('explain', options);
1049
+ if (o.profile !== undefined && o.profile !== 'rdfs') {
1050
+ throw new TypeError(`explain: unsupported profile '${o.profile}' (only 'rdfs')`);
1051
+ }
1052
+ const { profile: _profile, sourcesFormat, descriptionFormat, ...rest } = o;
1053
+ // The engine's option table has no entry for explain; the options here are read-only hints.
1054
+ const opts = rest;
1055
+ const fmt = opts.format || 'turtle';
1056
+ const srcNq = await nquadsOf(sources, { ...opts, format: sourcesFormat || fmt }, 'explain(sources)');
1057
+ const descNq = await nquadsOf(description, { ...opts, format: descriptionFormat || fmt }, 'explain(description)');
1058
+ const e = await entry();
1059
+ requireEntryFn(e, 'explainGraph', 'explain');
1060
+ const { ok: _ok, ...out } = entryResult(e.explainGraph(srcNq, descNq), 'explain');
1061
+ return out;
1062
+ }
1063
+
1026
1064
  /**
1027
1065
  * RDFS-Plus closure (RDFS with owl:sameAs, inverseOf, Symmetric,
1028
1066
  * Transitive, Functional and InverseFunctional properties,
@@ -2078,7 +2116,7 @@ function buildApi(driver) {
2078
2116
  * @returns {Promise<{entry: boolean, construct: boolean,
2079
2117
  * update: boolean, canonicalize: boolean, graphs: boolean,
2080
2118
  * canonicalHash: boolean, shacl: boolean, shex: boolean,
2081
- * owlClosure: boolean, rml: boolean, csvw: boolean, jsonld: boolean,
2119
+ * owlClosure: boolean, explain: boolean, stages: boolean, rml: boolean, csvw: boolean, jsonld: boolean,
2082
2120
  * rif: boolean}>}
2083
2121
  */
2084
2122
  let capsCache = null;
@@ -2099,6 +2137,8 @@ function buildApi(driver) {
2099
2137
  shacl: has('shaclValidate'),
2100
2138
  shex: has('shexValidate'),
2101
2139
  owlClosure: has('owlClosure'),
2140
+ explain: has('explainGraph'),
2141
+ stages: has('stageList'),
2102
2142
  entailment: has('queryRaw'),
2103
2143
  tableau: has('tableauMaterialise') && has('tableauDlInconsistent'),
2104
2144
  rml: has('rmlMap'),
@@ -2132,6 +2172,12 @@ function buildApi(driver) {
2132
2172
  };
2133
2173
  }
2134
2174
 
2175
+ // The node-composition API (lib/nodes.mjs, issue 1002 step 2).
2176
+ const nodeApi = buildNodes({
2177
+ entry, entryResult, requireEntryFn, Dataset,
2178
+ parse, closure, shaclValidate, query, serialize, canonicalize, explain,
2179
+ });
2180
+
2135
2181
  return {
2136
2182
  parse,
2137
2183
  query,
@@ -2154,6 +2200,11 @@ function buildApi(driver) {
2154
2200
  owlClosure,
2155
2201
  coreRdfsClosure,
2156
2202
  coreRdfsCheck,
2203
+ explain,
2204
+ stages: nodeApi.stages,
2205
+ node: nodeApi.node,
2206
+ pipe: nodeApi.pipe,
2207
+ run: nodeApi.run,
2157
2208
  rdfsPlusClosure,
2158
2209
  rhoDfClosure,
2159
2210
  rhoDfFragmentCheck,
package/lib/dataset.mjs CHANGED
@@ -152,6 +152,7 @@ class Dataset {
152
152
  this._prefixes = Object.freeze({ ...(prefixes || {}) });
153
153
  const x = extra || {};
154
154
  this._diagnostics = Object.freeze((x.diagnostics || []).slice());
155
+ this._warnings = Object.freeze((x.warnings || []).slice());
155
156
  this._format = x.format;
156
157
  this._formatInferred = !!x.formatInferred;
157
158
  this._info = Object.freeze({ ...(x.info || {}) });
@@ -169,6 +170,13 @@ class Dataset {
169
170
  get prefixes() { return this._prefixes; }
170
171
  /** Diagnostics of a lenient parse ({lenient:true}); else empty. */
171
172
  get diagnostics() { return this._diagnostics; }
173
+ /**
174
+ * Warnings of the parse: one per language tag that is not well-formed
175
+ * under BCP 47 (class 'ill-formed-language-tag'). The triple is kept and
176
+ * the writers write the tag unchanged. Empty for a Dataset not read from
177
+ * text.
178
+ */
179
+ get warnings() { return this._warnings; }
172
180
  /** The syntax the data was read from, when it was read from text. */
173
181
  get format() { return this._format; }
174
182
  /** True when `format` was inferred because the caller gave none. */
@@ -0,0 +1,84 @@
1
+ // The evidence logic of the node-composition API (issue 1002), kept apart
2
+ // from the engine so it runs, and is tested, without the wasm.
3
+ //
4
+ // Rule (owner-approved, issue 975 comment 6393905605 item 3): a stage that
5
+ // is not `proved` may run only after an explicit opt-in, and its evidence
6
+ // record says so. A result never reports a guarantee the path lacks:
7
+ // `proved` is true only when every record is `checked`.
8
+ //
9
+ // ES module, no Node built-ins.
10
+
11
+ import { FactoidalError } from './errors.mjs';
12
+
13
+ /** A stage is checked exactly when its registry status is `proved`. */
14
+ export const isChecked = (status) => status === 'proved';
15
+
16
+ /** The reason text of an unchecked step. */
17
+ export function reasonOf(n) {
18
+ if (n.status === 'missing') return 'no contract';
19
+ return n.assumptions.length > 0
20
+ ? `status ${n.status}; assumptions: ${n.assumptions.join(', ')}`
21
+ : `status ${n.status}`;
22
+ }
23
+
24
+ /**
25
+ * The refusal for node `n` before anything runs, or null. `hasRunner` is
26
+ * whether the package has a function that runs the stage.
27
+ * Without the opt-in a `missing` stage refuses (op.failed). With it, a stage
28
+ * runs when it has a runner; one with no runner refuses (op.unknown).
29
+ */
30
+ export function refusalFor(n, unchecked, hasRunner) {
31
+ if (!n.runnable && !unchecked) {
32
+ return new FactoidalError(`run: stage '${n.stage}' has status ${n.status}: ` +
33
+ 'no contract and no test suite, so it does not run as a node ' +
34
+ '(pass { unchecked: true } to run it without a guarantee)', { code: 'op.failed' });
35
+ }
36
+ if (!hasRunner) {
37
+ return new FactoidalError(`run: stage '${n.stage}' has no package function that runs it`,
38
+ { code: 'op.unknown' });
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * One evidence record. `checked` is true iff the status is `proved`.
45
+ * A step that ran under the opt-in and is not checked also carries
46
+ * `unchecked: true` and `reason`.
47
+ */
48
+ export function makeRecord(n, artefact, unchecked) {
49
+ const checked = isChecked(n.status);
50
+ const base = {
51
+ stage: n.stage,
52
+ status: n.status,
53
+ contract: n.contract,
54
+ assumptions: n.assumptions,
55
+ checked,
56
+ artefact: artefact === undefined ? null : artefact,
57
+ };
58
+ if (unchecked && !checked) {
59
+ base.unchecked = true;
60
+ base.reason = reasonOf(n);
61
+ }
62
+ return Object.freeze(base);
63
+ }
64
+
65
+ /** A record is checked; a record without `checked` (older producer) falls back to its status. */
66
+ export const recordChecked = (r) => (typeof r.checked === 'boolean' ? r.checked : isChecked(r.status));
67
+
68
+ /** The union of the named assumptions of the records, first-seen order. */
69
+ export function unionAssumptions(records) {
70
+ const seen = new Set();
71
+ const out = [];
72
+ for (const r of records) {
73
+ for (const a of r.assumptions) {
74
+ if (!seen.has(a)) { seen.add(a); out.push(a); }
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** `{ assumptions, proved, uncheckedSteps }` of a record list. */
81
+ export function summarize(records) {
82
+ const uncheckedSteps = records.filter((r) => !recordChecked(r)).map((r) => r.stage);
83
+ return { assumptions: unionAssumptions(records), proved: uncheckedSteps.length === 0, uncheckedSteps };
84
+ }
package/lib/nodes.mjs ADDED
@@ -0,0 +1,215 @@
1
+ // The node-composition API (https://github.com/danbri/factoidal/issues/1002,
2
+ // step 2): registered Lean stages as nodes, each carrying its contract name
3
+ // and its proof status, with evidence flowing on the wires.
4
+ //
5
+ // The stage list is the engine's (op stageList, formal/lean4/Wasm/Ops/
6
+ // StageList.lean), read from the Lean registry
7
+ // L4Factoidal.Dataflow.registry: name, interface type, wire types, status,
8
+ // contract, named assumptions, and whether the stage may run at all
9
+ // (`runnable`, false for status `missing`). This file holds no copy of it.
10
+ //
11
+ // What this file adds is the binding of a stage NAME to the package
12
+ // function that runs it (RUNNERS below; each is the function that
13
+ // foundation/entrypoints.toml maps to that stage), and the wiring: a value
14
+ // on a wire is `{ graph, evidence }`, and each node appends one evidence
15
+ // record `{ stage, status, contract, assumptions, checked, artefact }` (plus
16
+ // `unchecked` and `reason` for a step run under run()'s `unchecked` opt-in;
17
+ // the record logic is in evidence.mjs). The result of
18
+ // a run also reports the union of the named assumptions of the nodes it
19
+ // passed: the premise list of the pipeline's soundness statement
20
+ // (Dataflow/EndToEnd.lean) as the user sees it.
21
+ //
22
+ // ES module, no Node built-ins: the browser loads this file as it is.
23
+
24
+ import { FactoidalError } from './errors.mjs';
25
+ import { refusalFor, makeRecord, summarize, unionAssumptions } from './evidence.mjs';
26
+
27
+ /**
28
+ * @param {object} deps the package functions and the engine entry
29
+ * @returns {{stages: Function, node: Function, pipe: Function, run: Function}}
30
+ */
31
+ export function buildNodes(deps) {
32
+ const { entry, entryResult, requireEntryFn, Dataset,
33
+ parse, closure, shaclValidate, query, serialize, canonicalize, explain } = deps;
34
+
35
+ const reader = (format) => async (wire, params) => {
36
+ if (typeof wire.graph !== 'string') {
37
+ throw new TypeError(`node: a reader takes text on its wire (got ${typeof wire.graph})`);
38
+ }
39
+ const ds = await parse(wire.graph, { format, ...params });
40
+ return { graph: ds, artefact: { format } };
41
+ };
42
+ const reasoner = (regime) => async (wire, params) => ({
43
+ graph: await closure(wire.graph, regime, params),
44
+ artefact: { regime },
45
+ });
46
+ const writer = (format) => async (wire, params) => ({
47
+ artefact: { format, text: await serialize(wire.graph, { ...params, format }) },
48
+ });
49
+
50
+ // Stage name (stageList `name`) -> the package function that runs it.
51
+ // A runner answers `{ graph?, artefact }`; no `graph` passes the input
52
+ // graph on unchanged.
53
+ const RUNNERS = {
54
+ ntriplesDocumentReader: reader('ntriples'),
55
+ nquadsDocumentReader: reader('nquads'),
56
+ turtleDocumentReader: reader('turtle'),
57
+ trigDocumentReader: reader('trig'),
58
+ rdfxmlDocumentReader: reader('rdfxml'),
59
+ jsonldDocumentReader: reader('jsonld'),
60
+ rdfsReasoner: reasoner('rdfs'),
61
+ owlRlReasoner: reasoner('owl-rl'),
62
+ rhoDfReasoner: reasoner('rho-df'),
63
+ shaclValidator: async (wire, params) => {
64
+ const { shapes, ...opts } = params;
65
+ if (shapes === undefined) throw new TypeError('node shaclValidator: params.shapes is required');
66
+ const r = await shaclValidate(wire.graph, shapes, opts);
67
+ return { artefact: { conforms: r.conforms, report: r.report } };
68
+ },
69
+ shippedEvaluator: async (wire, params) => {
70
+ const { query: sparql, ...opts } = params;
71
+ if (typeof sparql !== 'string') throw new TypeError('node shippedEvaluator: params.query must be a string');
72
+ const answer = await query(wire.graph, sparql, opts);
73
+ return answer instanceof Dataset ? { graph: answer, artefact: { form: 'graph' } }
74
+ : { artefact: { answer } };
75
+ },
76
+ ntriplesDocumentWriter: writer('ntriples'),
77
+ nquadsDocumentWriter: writer('nquads'),
78
+ turtleDocumentWriter: writer('turtle'),
79
+ trigDocumentWriter: writer('trig'),
80
+ rdfxmlDocumentWriter: writer('rdfxml'),
81
+ rdfcStage: async (wire, params) => ({
82
+ artefact: { canonical: await canonicalize(wire.graph, params) },
83
+ }),
84
+ rdfsExplainGraphStage: async (wire, params) => {
85
+ const { description, ...opts } = params;
86
+ if (description === undefined) {
87
+ throw new TypeError('node rdfsExplainGraphStage: params.description is required');
88
+ }
89
+ return { artefact: await explain(wire.graph, description, { ...opts, profile: 'rdfs' }) };
90
+ },
91
+ };
92
+
93
+ let stageCache = null;
94
+
95
+ /**
96
+ * The engine's stage list (op stageList), one element per registry entry.
97
+ * @returns {Promise<object[]>}
98
+ */
99
+ async function stages() {
100
+ if (!stageCache) {
101
+ stageCache = (async () => {
102
+ const e = await entry();
103
+ requireEntryFn(e, 'stageList', 'stages');
104
+ const r = entryResult(e.stageList(), 'stages');
105
+ return Object.freeze(r.stages.map((s) => Object.freeze({
106
+ ...s,
107
+ theorems: Object.freeze([...s.theorems]),
108
+ assumptions: Object.freeze([...s.assumptions]),
109
+ tests: Object.freeze([...s.tests]),
110
+ bound: Object.prototype.hasOwnProperty.call(RUNNERS, s.name),
111
+ })));
112
+ })();
113
+ stageCache.catch(() => { stageCache = null; });
114
+ }
115
+ return stageCache;
116
+ }
117
+
118
+ const NODE = Symbol('factoidal.node');
119
+
120
+ /**
121
+ * A node for the registered stage `name`, with its parameters (the
122
+ * second argument of the package function it calls, e.g. `{shapes}`,
123
+ * `{query}`, `{description}`, reader options).
124
+ * @param {string} name a stageList `name`
125
+ * @param {object} [params]
126
+ * @returns {Promise<object>}
127
+ */
128
+ async function node(name, params) {
129
+ if (typeof name !== 'string') throw new TypeError('node: name must be a string');
130
+ if (params !== undefined && (params === null || typeof params !== 'object')) {
131
+ throw new TypeError('node: params must be an object');
132
+ }
133
+ const info = (await stages()).find((s) => s.name === name);
134
+ if (!info) {
135
+ throw new FactoidalError(`node: no registered stage named '${name}' (see stages())`,
136
+ { code: 'option.invalid-value' });
137
+ }
138
+ return Object.freeze({
139
+ [NODE]: true,
140
+ stage: info.name,
141
+ kind: info.kind,
142
+ status: info.status,
143
+ contract: info.contract,
144
+ assumptions: info.assumptions,
145
+ wireIn: info.wireIn,
146
+ wireOut: info.wireOut,
147
+ runnable: info.runnable,
148
+ bound: info.bound,
149
+ params: Object.freeze({ ...(params || {}) }),
150
+ });
151
+ }
152
+
153
+ function nodesOf(items, who) {
154
+ const out = [];
155
+ for (const n of items) {
156
+ if (n && n[NODE]) out.push(n);
157
+ else if (n && Array.isArray(n.nodes)) out.push(...nodesOf(n.nodes, who));
158
+ else throw new TypeError(`${who}: expected nodes from node() or pipelines from pipe()`);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * A pipeline: the nodes in order (pipelines are flattened).
165
+ * `assumptions` is the union of the nodes' named assumptions.
166
+ * @returns {{nodes: object[], assumptions: string[]}}
167
+ */
168
+ function pipe(...items) {
169
+ const nodes = nodesOf(items, 'pipe');
170
+ if (nodes.length === 0) throw new TypeError('pipe: no nodes');
171
+ return Object.freeze({ nodes: Object.freeze(nodes), assumptions: Object.freeze(unionAssumptions(nodes)) });
172
+ }
173
+
174
+ const isWire = (v) => v !== null && typeof v === 'object' && !(v instanceof Dataset) &&
175
+ Object.prototype.hasOwnProperty.call(v, 'graph') && Array.isArray(v.evidence);
176
+
177
+ /**
178
+ * Run a node or a pipeline. `input` is a wire value `{graph, evidence}`
179
+ * or the graph itself (text for a reader, else a Dataset or data input).
180
+ * `options.unchecked: true` lets stages that are not `proved` run,
181
+ * `missing` ones too when the package has a runner for them; each such
182
+ * step's record says so (evidence.mjs). Without it, a `missing` stage
183
+ * refuses before any node runs.
184
+ * @returns {Promise<{graph: any, evidence: object[], assumptions: string[],
185
+ * proved: boolean, uncheckedSteps: string[]}>}
186
+ */
187
+ async function run(pipeline, input, options) {
188
+ if (options !== undefined && (options === null || typeof options !== 'object')) {
189
+ throw new TypeError('run: options must be an object');
190
+ }
191
+ const unchecked = !!(options && options.unchecked);
192
+ const nodes = nodesOf([pipeline], 'run');
193
+ for (const n of nodes) {
194
+ const why = refusalFor(n, unchecked, Object.prototype.hasOwnProperty.call(RUNNERS, n.stage));
195
+ if (why) throw why;
196
+ }
197
+ let wire = isWire(input) ? { graph: input.graph, evidence: [...input.evidence] }
198
+ : { graph: input, evidence: [] };
199
+ let owned = null; // a Dataset an earlier node of this run made
200
+ for (const n of nodes) {
201
+ const out = await RUNNERS[n.stage](wire, n.params);
202
+ const record = makeRecord(n, out.artefact, unchecked);
203
+ let graph = wire.graph;
204
+ if (out.graph !== undefined && out.graph !== wire.graph) {
205
+ if (owned && owned === wire.graph && typeof owned.dispose === 'function') owned.dispose();
206
+ graph = out.graph;
207
+ owned = out.graph instanceof Dataset ? out.graph : null;
208
+ }
209
+ wire = { graph, evidence: [...wire.evidence, record] };
210
+ }
211
+ return { graph: wire.graph, evidence: wire.evidence, ...summarize(wire.evidence) };
212
+ }
213
+
214
+ return { stages, node, pipe, run };
215
+ }
@@ -0,0 +1,61 @@
1
+ // The Node reader of an SBM11 generation directory for lib/sbm11.mjs: the
2
+ // source is a directory path, and a file of the generation is read with
3
+ // fs.readSync at an offset (https://github.com/danbri/factoidal/issues/1009
4
+ // KR1). It moves bytes only (iron rule 7); the engine checks every length.
5
+ //
6
+ // One open file descriptor per file, kept while the reader lives: a query
7
+ // makes many small reads of the same few files.
8
+
9
+ import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export function nodeDirectoryReader (dir) {
13
+ const fds = new Map()
14
+ function fdOf (file) {
15
+ let fd = fds.get(file)
16
+ if (fd === undefined) {
17
+ fd = openSync(join(dir, file), 'r')
18
+ fds.set(file, fd)
19
+ }
20
+ return fd
21
+ }
22
+ return {
23
+ readAt (file, offset, length) {
24
+ const out = new Uint8Array(length)
25
+ const fd = fdOf(file)
26
+ let done = 0
27
+ while (done < length) {
28
+ const n = readSync(fd, out, done, length - done, offset + done)
29
+ if (n === 0) break
30
+ done += n
31
+ }
32
+ return done === length ? out : null
33
+ },
34
+ size (file) {
35
+ try {
36
+ const fd = fds.get(file)
37
+ const st = fd === undefined ? statSync(join(dir, file)) : fstatSync(fd)
38
+ return st.isFile() ? st.size : null
39
+ } catch {
40
+ return null
41
+ }
42
+ },
43
+ close () {
44
+ for (const fd of fds.values()) closeSync(fd)
45
+ fds.clear()
46
+ }
47
+ }
48
+ }
49
+
50
+ /** A default-reader factory: one reader per directory, kept. */
51
+ export function nodeDefaultReaderFactory () {
52
+ const byDir = new Map()
53
+ return (source) => {
54
+ let r = byDir.get(source)
55
+ if (!r) {
56
+ r = nodeDirectoryReader(source)
57
+ byDir.set(source, r)
58
+ }
59
+ return r
60
+ }
61
+ }
package/lib/sbm11.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // SPARQL over an SBM11 generation, in Node and in the browser, through the
2
+ // wasm ops sbm11Open / sbm11Query / sbm11Close
3
+ // (formal/lean4/Wasm/Ops/Sbm11.lean; https://github.com/danbri/factoidal/issues/1009 KR1).
4
+ //
5
+ // WHAT THIS FILE IS ALLOWED TO DO
6
+ // Move bytes. The engine asks for (source, file, offset, length) through the
7
+ // bridge globalThis.__factoidalReadAt, and for the size of a file through
8
+ // globalThis.__factoidalFileSize (the EM_JS bodies in
9
+ // formal/lean4/ffi/l4_hostread.c call them synchronously). This file finds
10
+ // the reader that serves `source` and answers. It never reads a manifest,
11
+ // checks a length or a digest, or decides which file a query needs: the
12
+ // engine names every file and checks every answer against the manifest
13
+ // (formal/lean4/L4Factoidal/Storage/Sbm11/HostSession.lean). Iron rule 7.
14
+ //
15
+ // READERS
16
+ // A reader is { readAt(file, offset, length) -> Uint8Array, size(file) ->
17
+ // number | null }, both synchronous: the engine is inside WebAssembly and
18
+ // cannot wait for a promise. Node: lib/sbm11-node.mjs reads a directory with
19
+ // fs. A browser worker: a reader over OPFS FileSystemSyncAccessHandle
20
+ // (stage 4 of https://github.com/danbri/factoidal/issues/1007). A caller
21
+ // registers a reader under a source name; a source with no registered reader
22
+ // goes to the default reader (Node: the directory of that name).
23
+
24
+ const readers = new Map()
25
+ let defaultReader = null
26
+ let bridgeInstalled = false
27
+
28
+ function readerFor (source) {
29
+ const r = readers.get(source)
30
+ if (r) return r
31
+ if (defaultReader) return defaultReader(source)
32
+ return null
33
+ }
34
+
35
+ function installBridge () {
36
+ if (bridgeInstalled) return
37
+ bridgeInstalled = true
38
+ // A thrown error and a wrong length both reach Lean as "the host gave no
39
+ // bytes"; the engine then refuses the read with the file name.
40
+ globalThis.__factoidalReadAt = (source, file, offset, length) => {
41
+ const r = readerFor(source)
42
+ if (!r) return null
43
+ return r.readAt(file, offset, length)
44
+ }
45
+ globalThis.__factoidalFileSize = (source, file) => {
46
+ const r = readerFor(source)
47
+ if (!r) return null
48
+ return r.size(file)
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Serve `source` with `reader` ({ readAt(file, offset, length), size(file) },
54
+ * both synchronous). `null` removes the registration.
55
+ */
56
+ export function registerSbm11Source (source, reader) {
57
+ installBridge()
58
+ if (reader === null) { readers.delete(String(source)); return }
59
+ if (!reader || typeof reader.readAt !== 'function' || typeof reader.size !== 'function') {
60
+ throw new TypeError('registerSbm11Source: reader needs readAt(file, offset, length) and size(file)')
61
+ }
62
+ readers.set(String(source), reader)
63
+ }
64
+
65
+ /** The reader for a source with no registration: `(source) => reader | null`. */
66
+ export function setDefaultSbm11Reader (factory) {
67
+ installBridge()
68
+ defaultReader = factory
69
+ }
70
+
71
+ function envelope (raw) {
72
+ const j = typeof raw === 'string' ? JSON.parse(raw) : raw
73
+ if (!j || j.ok !== true) {
74
+ const e = new Error((j && j.error) || 'sbm11: unknown error')
75
+ e.code = 'SBM11'
76
+ throw e
77
+ }
78
+ return j
79
+ }
80
+
81
+ /**
82
+ * The SBM11 functions over an engine loader: `loadEngine()` resolves to the
83
+ * engine handle (`call(op, args)`).
84
+ */
85
+ export function sbm11Api (loadEngine) {
86
+ /**
87
+ * Open an SBM11 generation (a generation directory, or a collection root
88
+ * whose CURRENT file names one). Returns { generation, terms, query(sparql,
89
+ * options), close() }. `query` answers { path, rows, format, result, json }:
90
+ * `result` is the text `l4sbm11 query` writes (SPARQL JSON for SELECT and
91
+ * ASK, N-Triples for CONSTRUCT and DESCRIBE), `json` its parse for
92
+ * SELECT and ASK.
93
+ */
94
+ async function openSbm11 (source, options = {}) {
95
+ installBridge()
96
+ if (options.reader) registerSbm11Source(source, options.reader)
97
+ const eng = await loadEngine()
98
+ const opened = envelope(eng.call('sbm11Open', [String(source)]))
99
+ const handle = opened.handle
100
+ let open = true
101
+ return {
102
+ handle,
103
+ generation: opened.generation,
104
+ terms: Number(opened.terms),
105
+ query (sparql, qopts = {}) {
106
+ if (!open) throw new Error('sbm11: the store is closed')
107
+ const o = {}
108
+ if (qopts.base != null) o.base = String(qopts.base)
109
+ if (qopts.sparql12) o.sparql12 = true
110
+ if (qopts.reference) o.reference = true
111
+ const args = [handle, String(sparql), JSON.stringify(o)]
112
+ // The byte-region entry returns the result bytes outside the
113
+ // envelope; a large answer is then not escaped into a JSON string
114
+ // and parsed again (Wasm/Ops/Sbm11.lean sbm11QueryBlob).
115
+ let j, bytes
116
+ if (typeof eng.callBlobIO === 'function') {
117
+ const answer = eng.callBlobIO('sbm11Query', args, new Uint8Array(0))
118
+ j = envelope(answer.envelope)
119
+ bytes = answer.bytes
120
+ } else {
121
+ j = envelope(eng.call('sbm11Query', args))
122
+ }
123
+ let text = bytes === undefined ? j.result : undefined
124
+ return {
125
+ path: j.path,
126
+ rows: Number(j.rows),
127
+ format: j.format,
128
+ bytesRead: Number(j.bytesRead),
129
+ reads: Number(j.reads),
130
+ /** the answer as bytes (UTF-8) */
131
+ get bytes () { return bytes ?? new TextEncoder().encode(text) },
132
+ /** the answer as text */
133
+ get result () {
134
+ if (text === undefined) text = new TextDecoder().decode(bytes)
135
+ return text
136
+ },
137
+ get json () { return j.format === 'srj' ? JSON.parse(this.result) : undefined }
138
+ }
139
+ },
140
+ close () {
141
+ if (!open) return
142
+ open = false
143
+ envelope(eng.call('sbm11Close', [handle]))
144
+ }
145
+ }
146
+ }
147
+ return { openSbm11 }
148
+ }
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.13.0';
5
+ export const version = '0.14.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
5
5
  "keywords": [
6
6
  "sparql",
package/sample-store.mjs CHANGED
@@ -27,7 +27,7 @@ ex:c3 a skos:Concept ; skos:inScheme ex:scheme ; skos:prefLabel "SECAM"@en ; sko
27
27
  export const sampleStoreFacts = Object.freeze({
28
28
  triples: 15,
29
29
  source: 'SAMPLE_TURTLE in sample-store.mjs (written for this package)',
30
- layout: 'ibk3'
30
+ layout: 'ibk5'
31
31
  })
32
32
 
33
33
  /**