fux-engine 2.0.0-alpha.7 → 2.0.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/README.md CHANGED
@@ -1,45 +1,107 @@
1
- # fux-engine
1
+ # fux-engine — the Node read plane
2
2
 
3
3
  Read a [fux](https://github.com/arpitarya/fux) index from Node. **The command
4
4
  is `fux`; the package is `fux-engine`** — `fux` was taken on npm in 2016.
5
5
 
6
+ > **This file ships twice**, on purpose: it is the npm package's page, and
7
+ > `fux setup` vendors it into your repository at `.fux/node/README.md`. So it is
8
+ > written for whoever finds it, in either place.
9
+
10
+ ## Run it
11
+
12
+ **In a repository that has a fux index, always through the shim:**
13
+
14
+ ```console
15
+ $ .fux/fux find rollback
16
+ $ .fux/fux ask "how do we roll back a release" --band
17
+ $ .fux/fux answer "retry policy" --json
18
+ ```
19
+
20
+ 🔴 **`.fux/fux` is the one entry point that is correct in every shape**, which
21
+ is why it is the only one documented. It resolves the reader in three rungs —
22
+ the vendored bundle, then `.fux/node/node_modules/.bin/fux`, then every
23
+ ancestor's `node_modules/.bin/fux` — because npm and Yarn hoist that binary to
24
+ the workspace root and **pnpm and bun do not**. Naming a path instead would be
25
+ right for half the ecosystem and silently wrong for the other half.
26
+
27
+ **Without a repository, or to try it:**
28
+
6
29
  ```console
7
- $ npx fux-engine find rollback
8
- $ node .fux/fux.mjs find rollback # a clone with nothing installed
30
+ $ npx fux-engine --version
9
31
  ```
10
32
 
33
+ **As a library:**
34
+
11
35
  ```js
12
36
  import { open } from 'fux-engine'
13
37
  const ix = await open('.')
14
38
  await ix.find('rollback', { top: 5 })
15
39
  ```
16
40
 
41
+ ## What is in `.fux/node/`
42
+
43
+ `fux setup` writes one of two shapes, and it detects which:
44
+
45
+ | | what is there | offline | how it runs |
46
+ |---|---|---|---|
47
+ | **the default** | `fux.mjs` (one generated file) · `package.json` · `mcp-tools.json` · this file | **yes** — nothing to install | `.fux/fux …` |
48
+ | **a monorepo** | `package.json` only, declaring `fux-engine@<version>` | after an install | `.fux/fux …`, once your package manager has run |
49
+
50
+ In the monorepo shape `.fux/node` is declared as a **workspace member** in your
51
+ root manifest — `fux setup` adds that one line, says so, and never adds it
52
+ twice. Run your package manager's `install` afterwards; `fux doctor` tells you
53
+ if you have not.
54
+
55
+ 🔴 **`fux.mjs` is GENERATED. Do not edit it.** It is rebuilt from fux's sources
56
+ at publish time and overwritten whenever the engine version changes. To see what
57
+ it does, read `node/fux.mjs` and `node/src/**` in the fux repository at the
58
+ matching tag — or regenerate it and compare bytes:
59
+
60
+ ```console
61
+ $ python -m fux.store.nodebundle node node/dist
62
+ ```
63
+
17
64
  ## What it is
18
65
 
19
66
  A fux index is a small, plain-text index committed to git — statistics about
20
67
  your documents, never the documents. Python writes it; **Python or Node reads
21
68
  it, and the two are held byte-equal by a differential law.**
22
69
 
23
- - **Zero dependencies.** No `dependencies` key in `package.json` at all, and
24
- no build step. A build step is a dependency.
70
+ - **Zero dependencies, and no build step *for you*.** There is no
71
+ `dependencies` key in the published `package.json` at all. Fux's own release
72
+ pipeline has a bundler; nobody running `fux setup` does, and nothing is
73
+ compiled on your machine.
74
+ - **One artefact, not a module tree.** What lands in your repository is one
75
+ generated file, so a fux upgrade is a one-file diff and a tampered reader is
76
+ detectable by rebuilding it. Before 2026-09-12 it was 47 of fux's own `.mjs`
77
+ files in your tree; `fux setup` deletes them when it upgrades you.
25
78
  - **It never writes and never fetches.** `ingest`, `build`, `add`, `enrich`
26
79
  and every other write verb belongs to Python fux, and typing one here tells
27
80
  you so rather than saying *unknown command*.
28
81
  - **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).
82
+ scores equal after `round(9)`, which is the sort key's own resolution.
83
+ - **Two things it deliberately does not do.** It cannot read a document that
84
+ needs a decoder (a `.pdf`, a `.xlsx`, your own `.fux/decoders/*.py`), so
85
+ `answer` **declines** to cite one rather than quoting line numbers into text
86
+ the index never held; and it never touches the network.
31
87
 
32
88
  ## Two `fux` commands on one PATH
33
89
 
34
90
  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:
91
+ `--version` names the runtime, so no bug report is ever ambiguous:
36
92
 
37
93
  ```console
38
94
  $ fux --version
39
- fux 2.0.0-alpha.7 (node 22.9.0)
95
+ fux 2.0.0 (node 24.13.0)
40
96
  ```
41
97
 
42
- ## Status
98
+ `fux doctor` (Python's) reports when a Node `fux` sits earlier on your PATH.
99
+
100
+ ## Verbs
101
+
102
+ `find` · `ask` · `answer` · `explain` · `graph` · `path` · `mcp`
43
103
 
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.
104
+ `fux mcp` serves the index over the Model Context Protocol on stdio —
105
+ `fux_search`, `fux_passage`, `fux_related` — so an agent can query it with no
106
+ glue. `verify`, `--why`, `--receipt` and `--journal` have no Node twin; they are
107
+ Python's.
package/fux.mjs CHANGED
Binary file
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "fux-engine",
3
- "version": "2.0.0-alpha.7",
3
+ "version": "2.0.0",
4
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
5
  "type": "module",
6
6
  "engines": { "node": ">=20" },
7
7
  "bin": { "fux": "./fux.mjs" },
8
- "exports": { ".": "./src/index.mjs" },
9
- "files": ["fux.mjs", "src", "mcp-tools.json", "README.md"],
8
+ "exports": { ".": "./fux.mjs" },
9
+ "files": ["fux.mjs", "mcp-tools.json", "README.md"],
10
10
  "license": "MIT",
11
11
  "repository": { "type": "git", "url": "git+https://github.com/arpitarya/fux.git" },
12
12
  "homepage": "https://github.com/arpitarya/fux#readme",
@@ -1,95 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
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
- }
@@ -1,52 +0,0 @@
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
- }
@@ -1,59 +0,0 @@
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
- }
@@ -1,67 +0,0 @@
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 };
@@ -1,51 +0,0 @@
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
- }