fux-engine 2.0.0-alpha.7
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/README.md +45 -0
- package/fux.mjs +139 -0
- package/mcp-tools.json +66 -0
- package/package.json +16 -0
- package/src/compat/pyfloat.mjs +95 -0
- package/src/config/root.mjs +16 -0
- package/src/decode/markdown.mjs +52 -0
- package/src/graph/community.mjs +59 -0
- package/src/graph/model.mjs +67 -0
- package/src/graph/plane.mjs +51 -0
- package/src/hash/blake2b.mjs +208 -0
- package/src/index.mjs +38 -0
- package/src/ingest/priors.mjs +35 -0
- package/src/query/analyzer.mjs +81 -0
- package/src/query/bm25f.mjs +89 -0
- package/src/query/confidence.mjs +163 -0
- package/src/query/expand.mjs +55 -0
- package/src/query/fuse.mjs +49 -0
- package/src/query/headings.mjs +30 -0
- package/src/query/rank.mjs +158 -0
- package/src/query/run.mjs +74 -0
- package/src/query/scan.mjs +106 -0
- package/src/query/stem.mjs +172 -0
- package/src/query/tokenize.mjs +11 -0
- package/src/refer/assemble.mjs +82 -0
- package/src/refer/chunk.mjs +263 -0
- package/src/refer/freshness.mjs +51 -0
- package/src/refer/rescore.mjs +106 -0
- package/src/refer/source.mjs +45 -0
- package/src/store/format.mjs +37 -0
- package/src/store/reader.mjs +87 -0
- package/src/verbs/answer.mjs +143 -0
- package/src/verbs/ask.mjs +48 -0
- package/src/verbs/find.mjs +24 -0
- package/src/verbs/graph.mjs +110 -0
- package/src/verbs/mcp.mjs +145 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# fux-engine
|
|
2
|
+
|
|
3
|
+
Read a [fux](https://github.com/arpitarya/fux) index from Node. **The command
|
|
4
|
+
is `fux`; the package is `fux-engine`** — `fux` was taken on npm in 2016.
|
|
5
|
+
|
|
6
|
+
```console
|
|
7
|
+
$ npx fux-engine find rollback
|
|
8
|
+
$ node .fux/fux.mjs find rollback # a clone with nothing installed
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { open } from 'fux-engine'
|
|
13
|
+
const ix = await open('.')
|
|
14
|
+
await ix.find('rollback', { top: 5 })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it is
|
|
18
|
+
|
|
19
|
+
A fux index is a small, plain-text index committed to git — statistics about
|
|
20
|
+
your documents, never the documents. Python writes it; **Python or Node reads
|
|
21
|
+
it, and the two are held byte-equal by a differential law.**
|
|
22
|
+
|
|
23
|
+
- **Zero dependencies.** No `dependencies` key in `package.json` at all, and
|
|
24
|
+
no build step. A build step is a dependency.
|
|
25
|
+
- **It never writes and never fetches.** `ingest`, `build`, `add`, `enrich`
|
|
26
|
+
and every other write verb belongs to Python fux, and typing one here tells
|
|
27
|
+
you so rather than saying *unknown command*.
|
|
28
|
+
- **Same answers as Python.** Same ids, same order, same locators, same band;
|
|
29
|
+
scores equal after `round(9)`, which is the sort key's own resolution
|
|
30
|
+
(ADR-RANKING decision 8a).
|
|
31
|
+
|
|
32
|
+
## Two `fux` commands on one PATH
|
|
33
|
+
|
|
34
|
+
Python's `fux-engine` installs a `fux` too, with a **different verb set**.
|
|
35
|
+
`--version` names the runtime so no bug report is ever ambiguous:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ fux --version
|
|
39
|
+
fux 2.0.0-alpha.7 (node 22.9.0)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Status
|
|
43
|
+
|
|
44
|
+
**W-107 Phase 1.** `find` is implemented and pinned against Python.
|
|
45
|
+
`ask` / `answer` land in Phase 2, the graph verbs and `mcp` in Phase 3.
|
package/fux.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** `fux` — the Node read plane. Reads an index Python wrote; never writes.
|
|
3
|
+
*
|
|
4
|
+
* npm package `fux-engine`, command `fux`, vendored by `fux setup` into
|
|
5
|
+
* `.fux/fux.mjs` so a clone with no Python still answers (W-107 R1/R2).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { findRoot } from "./src/config/root.mjs";
|
|
10
|
+
import { runFind } from "./src/verbs/find.mjs";
|
|
11
|
+
import { runAsk } from "./src/verbs/ask.mjs";
|
|
12
|
+
import { runAnswer } from "./src/verbs/answer.mjs";
|
|
13
|
+
import { runExplain, runGraph, runPath } from "./src/verbs/graph.mjs";
|
|
14
|
+
import { runMcp } from "./src/verbs/mcp.mjs";
|
|
15
|
+
|
|
16
|
+
const VERSION = "2.0.0-alpha.7";
|
|
17
|
+
|
|
18
|
+
/** The PII gate's path, spelled here the way `cli.py` and `api.py` spell it.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 **W-107 O1, ruled by Arpit 2026-09-12: Node enforces it identically.**
|
|
21
|
+
* Python refuses every non-exempt verb without `.fux/pii.toml`
|
|
22
|
+
* (ADR-PII decision 17). The gate exists because the committed index is
|
|
23
|
+
* redacted, so a Node reader that answered where Python refuses would be a
|
|
24
|
+
* divergence in the PRODUCT, not merely in the code — and this runtime cannot
|
|
25
|
+
* import the Python that carries the rule, so it carries the path itself.
|
|
26
|
+
* `tests/test_cli.py::test_the_node_reader_gates_on_the_same_file` holds this
|
|
27
|
+
* literal equal to the two Python copies. The WORDING below is a
|
|
28
|
+
* transcription of `pii.require`'s, which stays the one place it is authored.
|
|
29
|
+
*/
|
|
30
|
+
const PII_RULES = [".fux", "pii.toml"];
|
|
31
|
+
|
|
32
|
+
/** Every verb here reads the index, so every verb is gated. There is no Node
|
|
33
|
+
* twin of Python's `PII_EXEMPT` — `setup`, `tune`, `output` and `doctor` are
|
|
34
|
+
* all in `WRITE_VERBS` and never reach this point. */
|
|
35
|
+
function requirePiiRules(root) {
|
|
36
|
+
const path = join(root, ...PII_RULES);
|
|
37
|
+
if (existsSync(path) && statSync(path).isFile()) return true;
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`error: ${PII_RULES.join("/")} is missing, and fux will not run without it ` +
|
|
40
|
+
`(ADR-PII decision 17).\n` +
|
|
41
|
+
` Run \`fux setup\` to write the starter, then review its rules; to redact\n` +
|
|
42
|
+
` nothing, keep the file with no [[rule]] entries.\n`,
|
|
43
|
+
);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Verbs that exist in Python fux and deliberately not here. A person typing
|
|
48
|
+
* one has a specific wrong model and deserves the specific correction. */
|
|
49
|
+
const WRITE_VERBS = {
|
|
50
|
+
ingest: "writes the index", build: "writes the derived accelerator",
|
|
51
|
+
add: "writes a source line", remove: "writes a source line",
|
|
52
|
+
update: "re-fetches sources", enrich: "writes enrichment",
|
|
53
|
+
setup: "writes .fux/", doctor: "reports on a tree Node cannot fully see",
|
|
54
|
+
hooks: "installs git hooks", daemon: "runs a background re-index",
|
|
55
|
+
tune: "prints or writes tunables", output: "prints or writes output defaults",
|
|
56
|
+
verify: "not implemented until Phase 2 earns the digest path",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function parseArgs(argv) {
|
|
60
|
+
const out = { _: [], json: false, top: null };
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const a = argv[i];
|
|
63
|
+
if (a === "--json") out.json = true;
|
|
64
|
+
else if (a === "--top") out.top = parseInt(argv[++i], 10);
|
|
65
|
+
else if (a === "--under") out.under = argv[++i];
|
|
66
|
+
else if (a === "--phrase") out.phrase = argv[++i];
|
|
67
|
+
else if (a === "--all") out.all = true;
|
|
68
|
+
else if (a === "--band") out.band = true;
|
|
69
|
+
else if (a === "--no-sections") out.sections = false;
|
|
70
|
+
else if (a === "-q") (out.q ||= []).push(argv[++i]);
|
|
71
|
+
else if (a === "--expand") out.expand = argv[++i];
|
|
72
|
+
else if (a === "--no-refer") out.noRefer = true;
|
|
73
|
+
else if (a === "--audit") out.audit = true;
|
|
74
|
+
else if (a === "--hops") out.hops = parseInt(argv[++i], 10);
|
|
75
|
+
else if (a.startsWith("--")) { out.unknown = a; }
|
|
76
|
+
else out._.push(a);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function main(argv) {
|
|
82
|
+
const [verb, ...rest] = argv;
|
|
83
|
+
|
|
84
|
+
if (verb === "--version" || verb === "-V") {
|
|
85
|
+
// 🔴 The runtime is NAMED. Python fux installs a binary called `fux` too,
|
|
86
|
+
// so on a machine with both, this one line is what makes any bug report
|
|
87
|
+
// unambiguous about which reader answered (W-107 R1a).
|
|
88
|
+
process.stdout.write(`fux ${VERSION} (node ${process.versions.node})\n`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (!verb || verb === "--help" || verb === "-h") {
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
`fux ${VERSION} (node ${process.versions.node}) — the read plane\n\n` +
|
|
94
|
+
` fux find <query> [--json] [--top N] [--under DIR] [--phrase P] [--all]\n` +
|
|
95
|
+
` fux ask|answer|explain|graph|path|mcp (Phases 2-3)\n\n` +
|
|
96
|
+
`Reads an index Python wrote. It never writes and never fetches.\n`,
|
|
97
|
+
);
|
|
98
|
+
return verb ? 0 : 1;
|
|
99
|
+
}
|
|
100
|
+
if (verb in WRITE_VERBS) {
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
`error: \`${verb}\` ${WRITE_VERBS[verb]} — this is fux-engine (node), which only reads.\n` +
|
|
103
|
+
` Install Python fux and run \`fux ${verb}\`, or see .fux/README.md.\n`,
|
|
104
|
+
);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const args = parseArgs(rest);
|
|
109
|
+
const root = findRoot(process.cwd());
|
|
110
|
+
if (root === null) {
|
|
111
|
+
process.stderr.write("error: no fux root here — no fux.toml and no .git above this directory.\n");
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
// Before dispatch and before anything reads the repository, exactly where
|
|
115
|
+
// `cli.py::main` puts it, so no verb handler has to remember.
|
|
116
|
+
if (!requirePiiRules(root)) return 1;
|
|
117
|
+
|
|
118
|
+
switch (verb) {
|
|
119
|
+
case "find":
|
|
120
|
+
return runFind(root, args);
|
|
121
|
+
case "ask":
|
|
122
|
+
return runAsk(root, args);
|
|
123
|
+
case "answer":
|
|
124
|
+
return runAnswer(root, args);
|
|
125
|
+
case "explain":
|
|
126
|
+
return runExplain(root, args);
|
|
127
|
+
case "graph":
|
|
128
|
+
return runGraph(root, args);
|
|
129
|
+
case "path":
|
|
130
|
+
return runPath(root, args);
|
|
131
|
+
case "mcp":
|
|
132
|
+
return runMcp(root, args);
|
|
133
|
+
default:
|
|
134
|
+
process.stderr.write(`error: unknown verb \`${verb}\`. Try \`fux --help\`.\n`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
process.exitCode = main(process.argv.slice(2));
|
package/mcp-tools.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "The MCP tool descriptions, the authored text for node/src/verbs/mcp.mjs, held equal to src/fux/mcp.py's literal by tests/test_mcp.py. A description is what an agent reads to decide whether to call a tool, so two hand-maintained copies would drift into two products wearing one name. {{TOP}} is substituted with the connection's resolved [mcp] top.",
|
|
3
|
+
"tools": [
|
|
4
|
+
{
|
|
5
|
+
"name": "fux_search",
|
|
6
|
+
"description": "Search the committed index for documents relevant to a natural-language question. Returns ranked documents with content hashes and the headings that match your query -- section-level, not line-level; call fux_passage for the lines. This is the call to make first for any 'where is X' or 'why did we decide Y' question about this repository. READ THE `confidence` BLOCK BEFORE USING THE RESULTS. If `confidence.answerable` is false, do not answer from these results at all -- say what was searched and stop. If `confidence.band` is 'partial', answer but name every term in `confidence.missing`: those are words from the question that appear in no document here. If it is 'weak', the ranking could not separate the top hits, so report the candidates rather than a conclusion.",
|
|
7
|
+
"inputSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"query": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "A natural-language question."
|
|
13
|
+
},
|
|
14
|
+
"k": {
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"description": "Maximum results (default {{TOP}}; this repository's .fux/output.toml may set a different default under [mcp]).",
|
|
17
|
+
"default": "{{TOP}}"
|
|
18
|
+
},
|
|
19
|
+
"expand": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "OPTIONAL. Words you expect the document to use, when they differ from the question's. Scored at a lower weight than the question's own terms, and a document matching ONLY these is never returned. Use it after a search comes back `partial` with a non-empty `missing`: re-search with the vocabulary the corpus would use for the missing term."
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"required": [
|
|
25
|
+
"query"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "fux_passage",
|
|
31
|
+
"description": "Read the verbatim text of a cited span, addressed as returned by fux_search. Use this instead of reading a whole file when you already have a citation: it returns only the cited lines and the hash they were read at.",
|
|
32
|
+
"inputSchema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"path": {
|
|
36
|
+
"type": "string"
|
|
37
|
+
},
|
|
38
|
+
"line_start": {
|
|
39
|
+
"type": "integer"
|
|
40
|
+
},
|
|
41
|
+
"line_end": {
|
|
42
|
+
"type": "integer"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"required": [
|
|
46
|
+
"path"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "fux_related",
|
|
52
|
+
"description": "The neighbourhood around a document: what it links to, what links to it, what it supersedes or is superseded by, and whether it is archived. Use when a result raises 'what else was decided with this?'",
|
|
53
|
+
"inputSchema": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"path": {
|
|
57
|
+
"type": "string"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": [
|
|
61
|
+
"path"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fux-engine",
|
|
3
|
+
"version": "2.0.0-alpha.7",
|
|
4
|
+
"description": "Read a fux index from Node. Ranked, cited answers from a small index committed to git — no server, no model, no Python.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": ">=20" },
|
|
7
|
+
"bin": { "fux": "./fux.mjs" },
|
|
8
|
+
"exports": { ".": "./src/index.mjs" },
|
|
9
|
+
"files": ["fux.mjs", "src", "mcp-tools.json", "README.md"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/arpitarya/fux.git" },
|
|
12
|
+
"homepage": "https://github.com/arpitarya/fux#readme",
|
|
13
|
+
"bugs": { "url": "https://github.com/arpitarya/fux/issues" },
|
|
14
|
+
"keywords": ["knowledge", "documentation", "search", "index", "bm25", "adr", "runbook"],
|
|
15
|
+
"publishConfig": { "access": "public", "tag": "alpha" }
|
|
16
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Python float semantics JS does not share. No Python twin — declared exempt.
|
|
2
|
+
*
|
|
3
|
+
* Two of them reach the sort key, so both are correctness, not cosmetics.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Python's `round(x, ndigits)` — round-HALF-EVEN on the exact binary value.
|
|
7
|
+
*
|
|
8
|
+
* ⚠ `Number(x.toFixed(9))` agrees on ~200 000 random doubles and **fails on
|
|
9
|
+
* exact binary ties**, where `toFixed` rounds half-UP and Python rounds
|
|
10
|
+
* half-even. `round(score, 9)` is the sort key's own resolution
|
|
11
|
+
* ([ADR-RANKING decision 8a](../../../docs/adr/0111_ranking.md)), so a tie
|
|
12
|
+
* resolved the other way is a different ORDER — the one thing
|
|
13
|
+
* PRE-REGISTRATION-NODE §2 calls non-negotiable.
|
|
14
|
+
*
|
|
15
|
+
* The tie is detected by asking for more digits than `toFixed(9)` kept: if
|
|
16
|
+
* everything past the 9th digit is exactly `5` followed by zeros, the value
|
|
17
|
+
* sits on the boundary and the winner is the even neighbour.
|
|
18
|
+
*/
|
|
19
|
+
export function pyRound9(x) {
|
|
20
|
+
if (!Number.isFinite(x)) return x;
|
|
21
|
+
const s = x.toFixed(20); // enough digits to see the tie exactly
|
|
22
|
+
const dot = s.indexOf(".");
|
|
23
|
+
const tail = s.slice(dot + 1 + 9); // digits past the 9th decimal
|
|
24
|
+
const naive = Number(x.toFixed(9));
|
|
25
|
+
if (!/^50*$/.test(tail)) return naive; // not a tie — toFixed is already right
|
|
26
|
+
|
|
27
|
+
// Exact tie: choose the even neighbour.
|
|
28
|
+
const keep = s.slice(0, dot + 1 + 9);
|
|
29
|
+
const lower = Number(keep);
|
|
30
|
+
const step = 1e-9;
|
|
31
|
+
const lastDigit = Number(keep[keep.length - 1]);
|
|
32
|
+
if (lastDigit % 2 === 0) return lower;
|
|
33
|
+
return Number((lower + (x < 0 ? -step : step)).toFixed(9));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Python's `repr(float)` layout, which differs from `String(Number)`.
|
|
37
|
+
*
|
|
38
|
+
* Both are shortest-round-trip, so the DIGITS always agree; the exponent
|
|
39
|
+
* thresholds and the integral form do not:
|
|
40
|
+
*
|
|
41
|
+
* value Python repr JS String
|
|
42
|
+
* 2.0 "2.0" "2"
|
|
43
|
+
* 1e-5 "1e-05" "0.00001"
|
|
44
|
+
* 1e16 "1e+16" "10000000000000000"
|
|
45
|
+
*
|
|
46
|
+
* Python switches to exponent when `e < -4 or e >= 16`; JS at `-6 / 21`.
|
|
47
|
+
*/
|
|
48
|
+
export function pyRepr(x) {
|
|
49
|
+
if (Number.isNaN(x)) return "nan";
|
|
50
|
+
if (x === Infinity) return "inf";
|
|
51
|
+
if (x === -Infinity) return "-inf";
|
|
52
|
+
if (Number.isInteger(x) && Math.abs(x) < 1e16) {
|
|
53
|
+
return Object.is(x, -0) ? "-0.0" : `${x}.0`;
|
|
54
|
+
}
|
|
55
|
+
const sign = x < 0 ? "-" : "";
|
|
56
|
+
const a = Math.abs(x);
|
|
57
|
+
const e = Math.floor(Math.log10(a));
|
|
58
|
+
if (e < -4 || e >= 16) {
|
|
59
|
+
// ⚠ Python does NOT pad the mantissa here: repr(1e-5) is "1e-05", not
|
|
60
|
+
// "1.0e-05". The ".0" padding applies only to the integral form above.
|
|
61
|
+
const [mant, exp] = a.toExponential().split("e");
|
|
62
|
+
const n = Number(exp);
|
|
63
|
+
const es = (n < 0 ? "-" : "+") + String(Math.abs(n)).padStart(2, "0");
|
|
64
|
+
return `${sign}${mant}e${es}`;
|
|
65
|
+
}
|
|
66
|
+
return `${sign}${String(a)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compare two strings by CODE POINT, the way Python compares `str`.
|
|
70
|
+
*
|
|
71
|
+
* 🔴 **This is W-107 hazard H1, and it is why `<` is not used anywhere on a
|
|
72
|
+
* doc id.** JS compares strings by UTF-16 code UNIT, so a surrogate pair
|
|
73
|
+
* (anything above U+FFFF — an emoji, a rare CJK extension character) sorts
|
|
74
|
+
* BELOW U+E000–U+FFFF in JS and ABOVE it in Python. The sort key ends in
|
|
75
|
+
* `id`, so one such document orders differently in the two runtimes and the
|
|
76
|
+
* byte-equal ordering assertion fails with nothing else wrong.
|
|
77
|
+
*
|
|
78
|
+
* Iterating a string with `for..of` yields code points, not units, which is
|
|
79
|
+
* exactly the comparison Python makes.
|
|
80
|
+
*/
|
|
81
|
+
export function cmpCodePoints(a, b) {
|
|
82
|
+
if (a === b) return 0;
|
|
83
|
+
const ai = a[Symbol.iterator]();
|
|
84
|
+
const bi = b[Symbol.iterator]();
|
|
85
|
+
for (;;) {
|
|
86
|
+
const x = ai.next();
|
|
87
|
+
const y = bi.next();
|
|
88
|
+
if (x.done && y.done) return 0;
|
|
89
|
+
if (x.done) return -1;
|
|
90
|
+
if (y.done) return 1;
|
|
91
|
+
const cx = x.value.codePointAt(0);
|
|
92
|
+
const cy = y.value.codePointAt(0);
|
|
93
|
+
if (cx !== cy) return cx < cy ? -1 : 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Finding the repo root. Twin of `find_root` in `src/fux/config.py`.
|
|
2
|
+
* Walk up for `fux.toml` or `.git` — the same two tells, in the same order. */
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_NAME = "fux.toml";
|
|
7
|
+
|
|
8
|
+
export function findRoot(start = process.cwd()) {
|
|
9
|
+
let here = resolve(start);
|
|
10
|
+
for (;;) {
|
|
11
|
+
if (existsSync(join(here, CONFIG_NAME)) || existsSync(join(here, ".git"))) return here;
|
|
12
|
+
const up = dirname(here);
|
|
13
|
+
if (up === here) return null;
|
|
14
|
+
here = up;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** The ONE Markdown heading grammar, shared by ingest and the chunker.
|
|
2
|
+
* Twin of `src/fux/decode/_markdown.py`.
|
|
3
|
+
*
|
|
4
|
+
* **A fenced code block is content, not structure** — a `# comment` inside a
|
|
5
|
+
* ``` block is not a heading, and treating it as one splits a document where
|
|
6
|
+
* its author did not.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
10
|
+
/** Up to three leading spaces per CommonMark; a fourth makes it an indented
|
|
11
|
+
* code block, which cannot contain a heading anyway. */
|
|
12
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
13
|
+
|
|
14
|
+
/** `[lineno, line, fenced]` per line, `lineno` 1-based.
|
|
15
|
+
*
|
|
16
|
+
* The open fence is tracked as its DELIMITER STRING, not a boolean, so a
|
|
17
|
+
* closing fence must match its opener in both character and length: ``` inside
|
|
18
|
+
* a ~~~ block is content, and a shorter run of backticks does not close a
|
|
19
|
+
* longer one. */
|
|
20
|
+
export function* walk(markdown) {
|
|
21
|
+
let fence = null;
|
|
22
|
+
const lines = markdown.split("\n");
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i];
|
|
25
|
+
const lineno = i + 1;
|
|
26
|
+
const match = FENCE_RE.exec(line);
|
|
27
|
+
if (fence === null) {
|
|
28
|
+
if (match) { fence = match[1]; yield [lineno, line, true]; }
|
|
29
|
+
else yield [lineno, line, false];
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const closes = match !== null
|
|
33
|
+
&& match[1][0] === fence[0]
|
|
34
|
+
&& match[1].length >= fence.length
|
|
35
|
+
// A closing fence carries no info string.
|
|
36
|
+
&& match[2].trim() === "";
|
|
37
|
+
if (closes) fence = null;
|
|
38
|
+
yield [lineno, line, true];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every ATX heading outside a code fence, in document order.
|
|
43
|
+
* `lineno` is 1-based, matching the `path:L12-L40` citation format. */
|
|
44
|
+
export function headings(markdown) {
|
|
45
|
+
const found = [];
|
|
46
|
+
for (const [lineno, line, fenced] of walk(markdown)) {
|
|
47
|
+
if (fenced) continue;
|
|
48
|
+
const match = HEADING_RE.exec(line);
|
|
49
|
+
if (match) found.push({ lineno, level: match[1].length, text: match[2].trim() });
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Label propagation, determinized. Twin of `src/fux/graph/community.py`.
|
|
2
|
+
*
|
|
3
|
+
* An isolated node is its own community — honest rather than tidy: it says
|
|
4
|
+
* *this document stands alone*, which is a fact a reader wants, and it costs
|
|
5
|
+
* one entry.
|
|
6
|
+
*/
|
|
7
|
+
import { cmpCodePoints } from "../compat/pyfloat.mjs";
|
|
8
|
+
|
|
9
|
+
export const MAX_SWEEPS = 20;
|
|
10
|
+
|
|
11
|
+
/** Rename raw labels to `c0`, `c1`, … by (size desc, smallest member).
|
|
12
|
+
* Without this, a community's id is whichever node won the propagation —
|
|
13
|
+
* stable for one corpus, and liable to rename every community when a single
|
|
14
|
+
* document is added. */
|
|
15
|
+
function canonicalize(labels) {
|
|
16
|
+
const members = new Map();
|
|
17
|
+
for (const [node, label] of labels) {
|
|
18
|
+
if (!members.has(label)) members.set(label, []);
|
|
19
|
+
members.get(label).push(node);
|
|
20
|
+
}
|
|
21
|
+
for (const group of members.values()) group.sort(cmpCodePoints);
|
|
22
|
+
|
|
23
|
+
const ordered = [...members.values()].sort((a, b) => {
|
|
24
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
25
|
+
return cmpCodePoints(a[0], b[0]);
|
|
26
|
+
});
|
|
27
|
+
const out = new Map();
|
|
28
|
+
ordered.forEach((group, i) => { for (const node of group) out.set(node, `c${i}`); });
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function assign(graph) {
|
|
33
|
+
if (!graph.nodes.length) return new Map();
|
|
34
|
+
|
|
35
|
+
const labels = new Map(graph.nodes.map((n) => [n, n]));
|
|
36
|
+
|
|
37
|
+
for (let sweep = 0; sweep < MAX_SWEEPS; sweep++) {
|
|
38
|
+
let changed = false;
|
|
39
|
+
for (const node of graph.nodes) { // sorted: the visit order IS the determinism
|
|
40
|
+
const neighbours = graph.neighbours(node);
|
|
41
|
+
if (!neighbours.length) continue;
|
|
42
|
+
const weightByLabel = new Map();
|
|
43
|
+
for (const [neighbour, grade] of neighbours) {
|
|
44
|
+
const label = labels.get(neighbour);
|
|
45
|
+
weightByLabel.set(label, (weightByLabel.get(label) ?? 0) + grade);
|
|
46
|
+
}
|
|
47
|
+
// Heaviest label wins; ties go to the SMALLEST id, never to chance.
|
|
48
|
+
let best = null, bestWeight = -Infinity;
|
|
49
|
+
for (const [label, weight] of weightByLabel) {
|
|
50
|
+
if (weight > bestWeight || (weight === bestWeight && cmpCodePoints(label, best) < 0)) {
|
|
51
|
+
best = label; bestWeight = weight;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (best !== labels.get(node)) { labels.set(node, best); changed = true; }
|
|
55
|
+
}
|
|
56
|
+
if (!changed) break;
|
|
57
|
+
}
|
|
58
|
+
return canonicalize(labels);
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** The graph's shape. Twin of `src/fux/graph/model.py`.
|
|
2
|
+
*
|
|
3
|
+
* **Every accessor returns SORTED lists, and that is L3, not tidiness.**
|
|
4
|
+
* Community assignment and PPR both accumulate over neighbours, and float
|
|
5
|
+
* accumulation is not associative — an unsorted traversal makes the same
|
|
6
|
+
* corpus produce different bytes on different runs.
|
|
7
|
+
*/
|
|
8
|
+
import { cmpCodePoints } from "../compat/pyfloat.mjs";
|
|
9
|
+
|
|
10
|
+
export const TAG_PREFIX = "tag:";
|
|
11
|
+
|
|
12
|
+
/** Python sorts an `Edge` dataclass field-by-field in declaration order:
|
|
13
|
+
* `(src, kind, dst, grade)`. Strings by code point, ints numerically. */
|
|
14
|
+
function cmpEdge(a, b) {
|
|
15
|
+
let c = cmpCodePoints(a.src, b.src); if (c) return c;
|
|
16
|
+
c = cmpCodePoints(a.kind, b.kind); if (c) return c;
|
|
17
|
+
c = cmpCodePoints(a.dst, b.dst); if (c) return c;
|
|
18
|
+
return a.grade - b.grade;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Python sorts a `(str, int)` tuple the same way. */
|
|
22
|
+
function cmpNeighbour(a, b) {
|
|
23
|
+
const c = cmpCodePoints(a[0], b[0]);
|
|
24
|
+
return c !== 0 ? c : a[1] - b[1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Graph {
|
|
28
|
+
constructor(edges) {
|
|
29
|
+
this.edges = [...edges].sort(cmpEdge);
|
|
30
|
+
this._out = new Map();
|
|
31
|
+
this._both = new Map();
|
|
32
|
+
const nodes = new Set();
|
|
33
|
+
for (const edge of this.edges) {
|
|
34
|
+
nodes.add(edge.src); nodes.add(edge.dst);
|
|
35
|
+
if (!this._out.has(edge.src)) this._out.set(edge.src, []);
|
|
36
|
+
this._out.get(edge.src).push(edge);
|
|
37
|
+
if (!this._both.has(edge.src)) this._both.set(edge.src, []);
|
|
38
|
+
this._both.get(edge.src).push([edge.dst, edge.grade]);
|
|
39
|
+
if (!this._both.has(edge.dst)) this._both.set(edge.dst, []);
|
|
40
|
+
this._both.get(edge.dst).push([edge.src, edge.grade]);
|
|
41
|
+
}
|
|
42
|
+
this.nodes = [...nodes].sort(cmpCodePoints);
|
|
43
|
+
for (const adjacency of this._both.values()) adjacency.sort(cmpNeighbour);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Outbound edges, canonically ordered. `path` and `explain` read this. */
|
|
47
|
+
outEdges(node) { return [...(this._out.get(node) ?? [])]; }
|
|
48
|
+
/** Undirected `[neighbour, grade]` pairs. Community and PPR read this. */
|
|
49
|
+
neighbours(node) { return [...(this._both.get(node) ?? [])]; }
|
|
50
|
+
/** Nodes that are documents — everything that is not a tag. */
|
|
51
|
+
documents() { return this.nodes.filter((n) => !n.startsWith(TAG_PREFIX)); }
|
|
52
|
+
get length() { return this.nodes.length; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lift the committed `edges` arrays into edges. They are already resolved and
|
|
56
|
+
* already dropped if dangling, so there is nothing to validate — only lift. */
|
|
57
|
+
export function edgesFromRecords(records) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const record of records) {
|
|
60
|
+
for (const edge of record.edges || []) {
|
|
61
|
+
out.push({ src: record.id, kind: edge.kind, dst: edge.dst, grade: Number(edge.grade) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out.sort(cmpEdge);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { cmpEdge };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The graph plane, rebuilt IN MEMORY. Twin of `src/fux/graph/plane.py`.
|
|
2
|
+
*
|
|
3
|
+
* 🔴 **Node does not read Python's `.fux/runtime/graph.json`** — it is a
|
|
4
|
+
* derived file and Node's contract is the committed plane. It rebuilds the
|
|
5
|
+
* same graph from the same records and **its digest must equal Python's**,
|
|
6
|
+
* which is W-107's N2 gate. Reading the derived file instead would prove
|
|
7
|
+
* nothing about whether the two agree.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { Graph, edgesFromRecords } from "./model.mjs";
|
|
11
|
+
import { assign } from "./community.mjs";
|
|
12
|
+
import { cmpCodePoints } from "../compat/pyfloat.mjs";
|
|
13
|
+
|
|
14
|
+
export const SCHEMA = "fux.graph.v1";
|
|
15
|
+
export const GRAPH_NAME = "graph.json";
|
|
16
|
+
|
|
17
|
+
export class GraphPlane {
|
|
18
|
+
constructor(graph, communities) { this.graph = graph; this.communities = communities; }
|
|
19
|
+
communityOf(node) { return this.communities.get(node) ?? null; }
|
|
20
|
+
members(label) {
|
|
21
|
+
return [...this.communities].filter(([, c]) => c === label).map(([n]) => n).sort(cmpCodePoints);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildPlane(records) {
|
|
26
|
+
const graph = new Graph(edgesFromRecords(records));
|
|
27
|
+
return new GraphPlane(graph, assign(graph));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The exact bytes Python's `build_plane` writes, so the digests can be
|
|
31
|
+
* compared without either side reading the other's file.
|
|
32
|
+
*
|
|
33
|
+
* A list of lists rather than a list of objects: machine-written and
|
|
34
|
+
* machine-read, and four values per edge beats four repeated keys at a
|
|
35
|
+
* million of them. */
|
|
36
|
+
export function planeBytes(plane) {
|
|
37
|
+
const communities = {};
|
|
38
|
+
for (const node of [...plane.communities.keys()].sort(cmpCodePoints)) {
|
|
39
|
+
communities[node] = plane.communities.get(node);
|
|
40
|
+
}
|
|
41
|
+
const payload = {
|
|
42
|
+
schema: SCHEMA,
|
|
43
|
+
edges: plane.graph.edges.map((e) => [e.src, e.kind, e.dst, e.grade]),
|
|
44
|
+
communities,
|
|
45
|
+
};
|
|
46
|
+
return JSON.stringify(payload) + "\n";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function planeDigest(plane) {
|
|
50
|
+
return createHash("sha256").update(planeBytes(plane), "utf8").digest("hex");
|
|
51
|
+
}
|