mlx-bun 0.0.7 → 0.0.9
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 +112 -20
- package/docs/design/batching-v2-plan.md +515 -0
- package/docs/design/bucketing-stage.md +196 -0
- package/docs/design/compat-cli-surface-design.md +11 -11
- package/docs/design/diffusion-gemma-port.md +602 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dreaming-nightly-pipeline.md +291 -0
- package/docs/design/dspark-speculative-decoding.md +106 -0
- package/docs/design/generic-model-support.md +381 -0
- package/docs/design/memory-inference-path.md +422 -0
- package/docs/design/memory-synthesis.md +287 -0
- package/docs/design/minicpm5-decode-megakernel.md +484 -0
- package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
- package/docs/design/orpo-dynamic-lambda.md +41 -0
- package/docs/design/orpo-training.md +35 -5
- package/docs/design/parallel-slots.md +12 -1
- package/docs/design/parity-tier-dag.md +13 -5
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/tauri-desktop-app.md +2 -2
- package/docs/design/the-dreaming-handoff.md +141 -0
- package/docs/design/the-dreaming-master-plan.md +446 -0
- package/docs/design/write-pipeline-entity.md +468 -0
- package/docs/investigations/curve-bisector-routing.md +1 -1
- package/docs/investigations/decode-roofline-lookagain.md +262 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -2
- package/docs/planning/curve-sampler-research-plan.md +331 -0
- package/docs/planning/memory-docs-and-dag-plan.md +185 -0
- package/docs/planning/pi-integration-review.md +178 -0
- package/docs/planning/release-notes-v0.0.9.md +122 -0
- package/docs/planning/web-ui-pass-plan.md +213 -0
- package/docs/planning/website-readme-pass-plan.md +224 -0
- package/docs/reference/cli.md +255 -0
- package/docs/reference/embedding.md +7 -2
- package/docs/reference/library-api.md +22 -3
- package/docs/reference/memory.md +5 -5
- package/docs/reference/models.md +137 -0
- package/docs/reference/orpo-quickstart.md +6 -0
- package/docs/reference/server-api.md +187 -9
- package/docs/reference/server-config.md +40 -9
- package/docs/reference/training.md +25 -11
- package/package.json +1 -1
- package/src/anthropic.ts +22 -0
- package/src/assets/curve-designer.html +1 -1
- package/src/chat-template.ts +25 -7
- package/src/cli.ts +1029 -86
- package/src/config.ts +54 -3
- package/src/diffusion/diffusion-generate.ts +436 -0
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +19 -7
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +196 -19
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/lora.ts +2 -2
- package/src/memory/article.ts +558 -0
- package/src/memory/chunk-validation.ts +150 -0
- package/src/memory/chunk.ts +305 -12
- package/src/memory/cluster.ts +344 -17
- package/src/memory/coverage.ts +134 -0
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +184 -45
- package/src/memory/entity.ts +203 -0
- package/src/memory/gate.ts +187 -0
- package/src/memory/history.ts +152 -0
- package/src/memory/ingest.ts +324 -11
- package/src/memory/model.ts +321 -0
- package/src/memory/normalize.ts +671 -0
- package/src/memory/parse.ts +22 -0
- package/src/memory/pipeline.ts +242 -30
- package/src/memory/prompts.ts +42 -0
- package/src/memory/query.ts +334 -0
- package/src/memory/reconcile.ts +734 -0
- package/src/memory/redirect.ts +108 -0
- package/src/memory/reindex.ts +325 -0
- package/src/memory/resolve.ts +307 -0
- package/src/memory/route.ts +272 -0
- package/src/memory/schedule.ts +6 -4
- package/src/memory/stages.ts +774 -0
- package/src/memory/synthesize.ts +1629 -20
- package/src/memory/tools.ts +446 -96
- package/src/memory/vault.ts +462 -35
- package/src/memory/wikify.ts +1057 -16
- package/src/mlx/ffi.ts +11 -0
- package/src/mlx/ops.ts +113 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/diffusion-gemma.ts +1026 -0
- package/src/model/factory.ts +35 -8
- package/src/model/flash-attention.ts +4 -2
- package/src/model/fused-decode-kernel.ts +14 -7
- package/src/model/fused-mlp-kernel.ts +169 -0
- package/src/model/fused-swiglu-kernel.ts +240 -0
- package/src/model/gemma4-base.ts +62 -6
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3.ts +276 -0
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +45 -7
- package/src/model/universal/archs.ts +332 -0
- package/src/model/universal/dense.ts +576 -0
- package/src/model/universal/modules.ts +163 -0
- package/src/model/universal/rope.ts +366 -0
- package/src/pi-provider.ts +1 -1
- package/src/pi-terminal.ts +29 -8
- package/src/pi-web.ts +19 -2
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +215 -7
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +272 -80
- package/src/serve/generation-gateway.ts +90 -18
- package/src/server.ts +643 -50
- package/src/spec/drafter.ts +81 -49
- package/src/spec/dspark/data-dflash.ts +156 -0
- package/src/spec/dspark/data.ts +201 -0
- package/src/spec/dspark/generate-dflash.ts +193 -0
- package/src/spec/dspark/generate.ts +290 -0
- package/src/spec/dspark/loss.ts +213 -0
- package/src/spec/dspark/module-dflash.ts +361 -0
- package/src/spec/dspark/module.ts +540 -0
- package/src/spec/dspark/sample.ts +112 -0
- package/src/spec/generate.ts +40 -19
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/diffusion-lora.ts +216 -0
- package/src/train/flash-cce.ts +70 -15
- package/src/train/fuse.ts +243 -0
- package/src/train/index.ts +3 -0
- package/src/train/job.ts +8 -0
- package/src/train/loss.ts +289 -30
- package/src/train/prefix-shared.ts +33 -5
- package/src/train/segmented.ts +67 -11
- package/src/train/trainer.ts +92 -28
- package/src/vision/diffusion-vision.ts +620 -0
- package/src/web/skills/memory/SKILL.md +40 -17
package/src/harness-pi.ts
CHANGED
|
@@ -22,7 +22,10 @@ export const PI_PROVIDER_ID = "mlx-bun";
|
|
|
22
22
|
* server still reports the true id in /v1/models and responses. Gives a
|
|
23
23
|
* clean `mlx-bun/local` handle instead of `mlx-bun/mlx-community/…`. */
|
|
24
24
|
export const PI_LOCAL_MODEL_ID = "local";
|
|
25
|
-
|
|
25
|
+
// 127.0.0.1 (the server's actual default bind), not "localhost": the generated
|
|
26
|
+
// extension runs under the USER'S pi (node/jiti, not Bun), so don't depend on
|
|
27
|
+
// how that runtime's resolver maps localhost.
|
|
28
|
+
export const DEFAULT_BASE_URL = "http://127.0.0.1:8080/v1";
|
|
26
29
|
|
|
27
30
|
export function defaultPiExtensionsDir(): string {
|
|
28
31
|
return join(homedir(), ".pi", "agent", "extensions");
|
|
@@ -63,22 +66,34 @@ export interface ServerModel {
|
|
|
63
66
|
contextWindow: number;
|
|
64
67
|
maxTokens: number;
|
|
65
68
|
reasoning: boolean;
|
|
69
|
+
vision: boolean;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
/** Discover
|
|
72
|
+
/** Discover the SERVED model from a running mlx-bun server; [] when
|
|
73
|
+
* unreachable. /v1/models lists the served model first (the only row
|
|
74
|
+
* carrying the capability extras: context_window/reasoning/vision) and then
|
|
75
|
+
* every other servable model in the registry — those are things `mlx-bun
|
|
76
|
+
* serve` COULD run, not models this server answers for, so they must not be
|
|
77
|
+
* registered with pi. */
|
|
69
78
|
export async function fetchServerModels(baseUrl: string, timeoutMs = 3000): Promise<ServerModel[]> {
|
|
70
79
|
try {
|
|
71
80
|
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
|
|
72
81
|
signal: AbortSignal.timeout(timeoutMs),
|
|
73
82
|
});
|
|
74
83
|
if (!res.ok) return [];
|
|
75
|
-
const body = (await res.json()) as {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
const body = (await res.json()) as {
|
|
85
|
+
data?: Array<{ id: string; context_window?: number; reasoning?: boolean; vision?: boolean }>;
|
|
86
|
+
};
|
|
87
|
+
const rows = body.data ?? [];
|
|
88
|
+
const served = rows.find((m) => m.context_window !== undefined) ?? rows[0];
|
|
89
|
+
if (!served) return [];
|
|
90
|
+
return [{
|
|
91
|
+
id: served.id,
|
|
92
|
+
contextWindow: served.context_window ?? 32768,
|
|
79
93
|
maxTokens: 8192,
|
|
80
|
-
reasoning: !!
|
|
81
|
-
|
|
94
|
+
reasoning: !!served.reasoning,
|
|
95
|
+
vision: !!served.vision,
|
|
96
|
+
}];
|
|
82
97
|
} catch {
|
|
83
98
|
return [];
|
|
84
99
|
}
|
|
@@ -113,16 +128,22 @@ export default async function (pi: any) {
|
|
|
113
128
|
if (res.ok) {
|
|
114
129
|
const { data } = await res.json();
|
|
115
130
|
if (Array.isArray(data) && data.length > 0) {
|
|
116
|
-
|
|
131
|
+
// The SERVED model is the row with the capability extras (first);
|
|
132
|
+
// the rest are servable-but-not-served registry entries.
|
|
133
|
+
const served = data.find((m: any) => m.context_window !== undefined) ?? data[0];
|
|
134
|
+
models = [{
|
|
117
135
|
id: ${JSON.stringify(PI_LOCAL_MODEL_ID)},
|
|
118
|
-
name: \`\${
|
|
119
|
-
reasoning: !!
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
name: \`\${served.id} (mlx-bun local)\`,
|
|
137
|
+
reasoning: !!served.reasoning,
|
|
138
|
+
compat: {
|
|
139
|
+
supportsDeveloperRole: false,
|
|
140
|
+
...(served.reasoning ? { thinkingFormat: "qwen-chat-template" } : {}),
|
|
141
|
+
},
|
|
142
|
+
input: served.vision ? ["text", "image"] : ["text"],
|
|
122
143
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
123
|
-
contextWindow:
|
|
144
|
+
contextWindow: served.context_window ?? 32768,
|
|
124
145
|
maxTokens: 8192,
|
|
125
|
-
}
|
|
146
|
+
}];
|
|
126
147
|
}
|
|
127
148
|
}
|
|
128
149
|
} catch {}
|
|
@@ -142,8 +163,13 @@ function modelEntry(m: ServerModel) {
|
|
|
142
163
|
id: PI_LOCAL_MODEL_ID,
|
|
143
164
|
name: `${m.id} (mlx-bun local)`,
|
|
144
165
|
reasoning: !!m.reasoning,
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
// Kept aligned with buildPiProvider (src/pi-provider.ts): the system
|
|
167
|
+
// prompt must arrive as `system`, not `developer`.
|
|
168
|
+
compat: {
|
|
169
|
+
supportsDeveloperRole: false,
|
|
170
|
+
...(m.reasoning ? { thinkingFormat: "qwen-chat-template" } : {}),
|
|
171
|
+
},
|
|
172
|
+
input: m.vision ? ["text", "image"] : ["text"],
|
|
147
173
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
148
174
|
contextWindow: m.contextWindow,
|
|
149
175
|
maxTokens: m.maxTokens,
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,10 @@ export { Weights } from "./weights";
|
|
|
13
13
|
export { Gemma4Model } from "./model/gemma4";
|
|
14
14
|
export { MiniCPM5Model } from "./model/minicpm5";
|
|
15
15
|
export { createModel, type RuntimeModel } from "./model/factory";
|
|
16
|
+
export { Qwen3Model } from "./model/qwen3";
|
|
16
17
|
export { generate } from "./generate";
|
|
17
18
|
export { loadTokenizer } from "./tokenizer";
|
|
19
|
+
export { embedOne, embedMany, isEmbeddingModel, withInstruction, type EmbedResult } from "./embed";
|
|
18
20
|
export { ChatTemplate } from "./chat-template";
|
|
19
21
|
export { chooseAutoModel, COEXIST_FRACTION, DEFAULT_REPO_ID, fit, largestRecommendedRepoId, recommendedRepoId, skuMatrix, thisMachine } from "./fit";
|
|
20
22
|
export { downloadModel } from "./download";
|
package/src/lora.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
|
|
|
77
77
|
* PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py.
|
|
78
78
|
* `rsLora` (recorded at train time) means the effective per-layer scale is
|
|
79
79
|
* α/√rank — the caller divides by √(that layer's rank). */
|
|
80
|
-
async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
|
|
80
|
+
export async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
|
|
81
81
|
for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
|
|
82
82
|
const f = Bun.file(`${dir}/${name}`);
|
|
83
83
|
if (await f.exists()) {
|
|
@@ -94,7 +94,7 @@ async function readAdapterScale(dir: string): Promise<{ scale: number; rank: num
|
|
|
94
94
|
throw new Error(`no adapter_config.json in ${dir}`);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function adapterWeightsFile(dir: string): string {
|
|
97
|
+
export function adapterWeightsFile(dir: string): string {
|
|
98
98
|
for (const name of ["adapters.safetensors", "adapter_model.safetensors"]) {
|
|
99
99
|
const p = `${dir}/${name}`;
|
|
100
100
|
if (existsSync(p)) return p;
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// mlx-bun memory — the article grammar parser/serializer.
|
|
2
|
+
//
|
|
3
|
+
// The Dreaming write pipeline emits Wikipedia-shaped articles: each entity gets
|
|
4
|
+
// one Markdown file with a fixed top-to-bottom skeleton —
|
|
5
|
+
// H1 → series banner → infobox → lead → sections → See also → Notes → References
|
|
6
|
+
// (see docs/design/the-dreaming-master-plan.md and write-pipeline-entity.md).
|
|
7
|
+
// This module reads that grammar deterministically: the structured outputs the
|
|
8
|
+
// read side traverses (infobox key-value facts, the [[wikilink]] graph, footnote
|
|
9
|
+
// bijection) are parsed here, never by the model.
|
|
10
|
+
//
|
|
11
|
+
// It owns the ONE fence scanner — `isFenceLine` / `fenceLabel` — so that this
|
|
12
|
+
// module and `vault.ts` (parseToc / extractSection / searchArticles) agree on
|
|
13
|
+
// exactly what "inside a fenced code block" means. `vault.ts` imports it; this
|
|
14
|
+
// module never imports from `vault.ts` (no cycle).
|
|
15
|
+
|
|
16
|
+
// ---- fence scanner (the one shared implementation) -------------------
|
|
17
|
+
|
|
18
|
+
/** A line that opens or closes a Markdown fenced block (```...). */
|
|
19
|
+
export function isFenceLine(line: string): boolean {
|
|
20
|
+
return /^```/.test(line);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The info string after a fence's backticks (e.g. ```info → "info"); null if
|
|
24
|
+
* the line is not a fence. A bare closing ``` yields "". */
|
|
25
|
+
export function fenceLabel(line: string): string | null {
|
|
26
|
+
const m = /^```(.*)$/.exec(line);
|
|
27
|
+
return m ? m[1]!.trim() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Total number of fence lines (``` openers/closers) in a document. A BALANCED
|
|
31
|
+
* article has an EVEN count; an odd count means a fence was opened and never
|
|
32
|
+
* closed, which leaves every fence-aware scanner (this module, vault.ts,
|
|
33
|
+
* normalize.ts, crosslink.ts) stuck "inside a code block" for the rest of the
|
|
34
|
+
* file — so trailing real sections (## References / ## See also) are read as
|
|
35
|
+
* code and never recognized. */
|
|
36
|
+
export function countFenceLines(content: string): number {
|
|
37
|
+
let n = 0;
|
|
38
|
+
for (const line of content.split(/\r?\n/)) if (isFenceLine(line)) n++;
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when the document's fences are balanced (an even number of fence lines),
|
|
43
|
+
* i.e. every opened fence is closed — the precondition every fence-aware parser
|
|
44
|
+
* depends on. */
|
|
45
|
+
export function hasBalancedFences(content: string): boolean {
|
|
46
|
+
return countFenceLines(content) % 2 === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const REPAIR_HEADING_RE = /^#{1,6}\s+\S/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deterministically repair an UNBALANCED code fence (odd fence-line count). Fences
|
|
53
|
+
* strictly alternate open/close, so an odd count means the LAST fence line is an
|
|
54
|
+
* opener that is never closed and swallows everything after it. The repair closes
|
|
55
|
+
* (or strips) that dangling fence so trailing headings stop being read as code:
|
|
56
|
+
*
|
|
57
|
+
* - prefer to CLOSE the fence right before the first ##-style heading the fence
|
|
58
|
+
* would otherwise swallow (rescuing ## References / ## See also);
|
|
59
|
+
* - if the dangling open has only blank lines before that heading (or before
|
|
60
|
+
* EOF), it is a SPURIOUS stray fence — strip the line instead of wrapping an
|
|
61
|
+
* empty block;
|
|
62
|
+
* - with no heading to protect, close at EOF (or strip when the tail is blank).
|
|
63
|
+
*
|
|
64
|
+
* A balanced article is returned byte-identical (changed: false); after a repair
|
|
65
|
+
* the count is even, so a second pass is a no-op (idempotent). Pure.
|
|
66
|
+
*/
|
|
67
|
+
export function repairFences(content: string): { content: string; changed: boolean } {
|
|
68
|
+
const lines = content.split(/\r?\n/);
|
|
69
|
+
const fenceIdx: number[] = [];
|
|
70
|
+
for (let i = 0; i < lines.length; i++) if (isFenceLine(lines[i]!)) fenceIdx.push(i);
|
|
71
|
+
if (fenceIdx.length % 2 === 0) return { content, changed: false };
|
|
72
|
+
|
|
73
|
+
const dangling = fenceIdx[fenceIdx.length - 1]!; // the unmatched opener
|
|
74
|
+
|
|
75
|
+
// The first heading the dangling fence would otherwise swallow.
|
|
76
|
+
let heading = -1;
|
|
77
|
+
for (let i = dangling + 1; i < lines.length; i++) {
|
|
78
|
+
if (REPAIR_HEADING_RE.test(lines[i]!)) {
|
|
79
|
+
heading = i;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stop = heading >= 0 ? heading : lines.length;
|
|
85
|
+
// Last non-blank content line strictly between the dangling opener and `stop`.
|
|
86
|
+
let j = stop - 1;
|
|
87
|
+
while (j > dangling && lines[j]!.trim() === "") j--;
|
|
88
|
+
|
|
89
|
+
let out: string[];
|
|
90
|
+
if (j === dangling) {
|
|
91
|
+
// Nothing but blanks under the dangling fence → it opens an empty block; strip it.
|
|
92
|
+
out = [...lines.slice(0, dangling), ...lines.slice(dangling + 1)];
|
|
93
|
+
} else {
|
|
94
|
+
// Close the fence right after the last real content line, before `stop`.
|
|
95
|
+
out = [...lines.slice(0, j + 1), "```", ...lines.slice(j + 1)];
|
|
96
|
+
}
|
|
97
|
+
const next = out.join("\n");
|
|
98
|
+
return { content: next, changed: next !== content };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- entity kinds (the only fixed structural constant) ---------------
|
|
102
|
+
|
|
103
|
+
/** The closed set of entity KINDS — a schema, not content. Infobox `type:`
|
|
104
|
+
* labels and categories are emergent (declared per-article); only `kind` is
|
|
105
|
+
* fixed. There is NO `kindOf(type)` map. See P1-T4c. */
|
|
106
|
+
export const ENTITY_KINDS = ["thing", "person", "domain", "project", "standard"] as const;
|
|
107
|
+
export type EntityKind = (typeof ENTITY_KINDS)[number];
|
|
108
|
+
|
|
109
|
+
/** Read an entity kind from a declared `kind:` value, validating against the
|
|
110
|
+
* closed set; anything unrecognized defaults to "thing". */
|
|
111
|
+
export function toEntityKind(value: string): EntityKind {
|
|
112
|
+
const t = value.trim().toLowerCase();
|
|
113
|
+
return (ENTITY_KINDS as readonly string[]).includes(t) ? (t as EntityKind) : "thing";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- infobox ----------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** Syntactic value-type of an infobox field, derived deterministically from the
|
|
119
|
+
* value's shape (never from a baked key→class map — schemas are emergent). */
|
|
120
|
+
export type FieldValueType = "scalar" | "entity-link" | "list" | "date";
|
|
121
|
+
|
|
122
|
+
export interface InfoboxField {
|
|
123
|
+
key: string;
|
|
124
|
+
/** Raw value text exactly as written (so serialize round-trips byte-for-byte). */
|
|
125
|
+
value: string;
|
|
126
|
+
/** True when the value names another entity as a `[[wikilink]]`. */
|
|
127
|
+
isEntityLink: boolean;
|
|
128
|
+
kind: FieldValueType;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface Infobox {
|
|
132
|
+
/** The emergent `type:` label (a short free-form noun phrase), or null if absent. */
|
|
133
|
+
type: string | null;
|
|
134
|
+
/** The declared `kind:` entity kind, validated; defaults to "thing". */
|
|
135
|
+
entityKind: EntityKind;
|
|
136
|
+
/** Every `key: value` line, in document order (includes `type`/`kind`/`aliases`). */
|
|
137
|
+
fields: InfoboxField[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const WIKILINK_RE = /\[\[[^\]]+\]\]/;
|
|
141
|
+
const DATE_RE = /^\d{4}-\d{2}(-\d{2})?$/;
|
|
142
|
+
|
|
143
|
+
function parseFieldLine(line: string): { key: string; value: string } | null {
|
|
144
|
+
const idx = line.indexOf(":");
|
|
145
|
+
if (idx < 0) return null;
|
|
146
|
+
const key = line.slice(0, idx).trim();
|
|
147
|
+
if (!key) return null;
|
|
148
|
+
return { key, value: line.slice(idx + 1).trim() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Comma-separated for `aliases`, `;`-separated for every other multi-value
|
|
152
|
+
* field (the infobox grammar). Single-value fields yield a one-element array. */
|
|
153
|
+
export function infoboxFieldValues(field: InfoboxField): string[] {
|
|
154
|
+
const sep = field.key === "aliases" ? "," : ";";
|
|
155
|
+
return field.value
|
|
156
|
+
.split(sep)
|
|
157
|
+
.map((v) => v.trim())
|
|
158
|
+
.filter((v) => v.length > 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function classifyFieldKind(key: string, value: string, isEntityLink: boolean): FieldValueType {
|
|
162
|
+
const isList = key === "aliases" ? value.includes(",") : value.includes(";");
|
|
163
|
+
if (isList) return "list";
|
|
164
|
+
if (isEntityLink) return "entity-link";
|
|
165
|
+
if (DATE_RE.test(value.trim())) return "date";
|
|
166
|
+
return "scalar";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findInfoboxBlock(lines: string[]): { start: number; end: number } | null {
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
if (isFenceLine(lines[i]!) && fenceLabel(lines[i]!) === "info") {
|
|
172
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
173
|
+
if (isFenceLine(lines[j]!)) return { start: i, end: j };
|
|
174
|
+
}
|
|
175
|
+
return null; // unterminated fence — not a usable infobox
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse the first fenced ```info block in an article (or a bare block). Returns
|
|
182
|
+
* null when there is no infobox. */
|
|
183
|
+
export function parseInfobox(md: string): Infobox | null {
|
|
184
|
+
const lines = md.split(/\r?\n/);
|
|
185
|
+
const block = findInfoboxBlock(lines);
|
|
186
|
+
if (!block) return null;
|
|
187
|
+
|
|
188
|
+
const fields: InfoboxField[] = [];
|
|
189
|
+
for (let i = block.start + 1; i < block.end; i++) {
|
|
190
|
+
const parsed = parseFieldLine(lines[i]!);
|
|
191
|
+
if (!parsed) continue;
|
|
192
|
+
const isEntityLink = WIKILINK_RE.test(parsed.value);
|
|
193
|
+
fields.push({
|
|
194
|
+
key: parsed.key,
|
|
195
|
+
value: parsed.value,
|
|
196
|
+
isEntityLink,
|
|
197
|
+
kind: classifyFieldKind(parsed.key, parsed.value, isEntityLink),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const typeField = fields.find((f) => f.key === "type");
|
|
202
|
+
const kindField = fields.find((f) => f.key === "kind");
|
|
203
|
+
return {
|
|
204
|
+
type: typeField ? typeField.value : null,
|
|
205
|
+
entityKind: kindField ? toEntityKind(kindField.value) : "thing",
|
|
206
|
+
fields,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Render an infobox back to its fenced ```info block. Round-trips a
|
|
211
|
+
* canonically-formatted infobox byte-for-byte. */
|
|
212
|
+
export function serializeInfobox(box: Infobox): string {
|
|
213
|
+
const body = box.fields.map((f) => `${f.key}: ${f.value}`).join("\n");
|
|
214
|
+
return "```info\n" + body + "\n```";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The declared `aliases:` for an article, split exactly per the grammar. */
|
|
218
|
+
export function infoboxAliases(box: Infobox): string[] {
|
|
219
|
+
const f = box.fields.find((f) => f.key === "aliases");
|
|
220
|
+
return f ? infoboxFieldValues(f) : [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- series banner ----------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const SERIES_RE = /^\*Part of a series on \[\[([^\]]+)\]\]\.?\*$/;
|
|
226
|
+
/** The `{{stub}}` template line — header region, not body prose. */
|
|
227
|
+
const STUB_TEMPLATE_RE = /^\{\{stub\}\}$/;
|
|
228
|
+
|
|
229
|
+
/** The series this article belongs to, from a `*Part of a series on [[X]].*`
|
|
230
|
+
* banner; returns the series entity name, or null. */
|
|
231
|
+
export function parseSeriesBanner(md: string): string | null {
|
|
232
|
+
const lines = md.split(/\r?\n/);
|
|
233
|
+
let inFence = false;
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
if (isFenceLine(line)) {
|
|
236
|
+
inFence = !inFence;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (inFence) continue;
|
|
240
|
+
const m = SERIES_RE.exec(line.trim());
|
|
241
|
+
if (m) return m[1]!.split("|")[0]!.split("#")[0]!.trim();
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- lead -------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
|
|
249
|
+
|
|
250
|
+
/** The lead abstract: prose between the header region (H1 / banner / infobox)
|
|
251
|
+
* and the first `##` section, as a single line; null when there is none. */
|
|
252
|
+
export function parseLead(md: string): string | null {
|
|
253
|
+
const lines = md.split(/\r?\n/);
|
|
254
|
+
const block = findInfoboxBlock(lines);
|
|
255
|
+
|
|
256
|
+
let inFence = false;
|
|
257
|
+
let h1 = -1;
|
|
258
|
+
let firstSection = lines.length;
|
|
259
|
+
for (let i = 0; i < lines.length; i++) {
|
|
260
|
+
const line = lines[i]!;
|
|
261
|
+
if (isFenceLine(line)) {
|
|
262
|
+
inFence = !inFence;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (inFence) continue;
|
|
266
|
+
const m = HEADING_RE.exec(line);
|
|
267
|
+
if (!m) continue;
|
|
268
|
+
if (m[1]!.length === 1) {
|
|
269
|
+
if (h1 < 0) h1 = i;
|
|
270
|
+
} else if (firstSection === lines.length) {
|
|
271
|
+
firstSection = i;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let start = h1 + 1;
|
|
276
|
+
if (block && block.end + 1 > start) start = block.end + 1;
|
|
277
|
+
|
|
278
|
+
const leadLines: string[] = [];
|
|
279
|
+
for (let i = start; i < firstSection; i++) {
|
|
280
|
+
const t = lines[i]!.trim();
|
|
281
|
+
if (!t) {
|
|
282
|
+
if (leadLines.length) break; // paragraph end
|
|
283
|
+
continue; // skip leading blanks
|
|
284
|
+
}
|
|
285
|
+
if (SERIES_RE.test(t)) continue;
|
|
286
|
+
leadLines.push(t);
|
|
287
|
+
}
|
|
288
|
+
return leadLines.length ? leadLines.join(" ") : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- structural skeleton ---------------------------------------------
|
|
292
|
+
|
|
293
|
+
export type SkeletonKind = "h1" | "series" | "infobox" | "lead" | "section" | "see-also" | "notes" | "references";
|
|
294
|
+
|
|
295
|
+
export interface SkeletonItem {
|
|
296
|
+
kind: SkeletonKind;
|
|
297
|
+
/** Heading text for h1/section/see-also/notes/references items. */
|
|
298
|
+
title?: string;
|
|
299
|
+
/** Heading depth (1 for h1, 2 for top-level sections). */
|
|
300
|
+
depth?: number;
|
|
301
|
+
/** 1-based inclusive line range of the region. */
|
|
302
|
+
startLine: number;
|
|
303
|
+
endLine: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The article's ordered skeleton with line ranges — the structural map the
|
|
307
|
+
* write pipeline patches section-by-section. */
|
|
308
|
+
export function articleStructure(md: string): SkeletonItem[] {
|
|
309
|
+
const lines = md.split(/\r?\n/);
|
|
310
|
+
const block = findInfoboxBlock(lines);
|
|
311
|
+
|
|
312
|
+
interface Heading {
|
|
313
|
+
depth: number;
|
|
314
|
+
title: string;
|
|
315
|
+
line: number;
|
|
316
|
+
}
|
|
317
|
+
const headings: Heading[] = [];
|
|
318
|
+
let inFence = false;
|
|
319
|
+
let bannerLine = -1;
|
|
320
|
+
for (let i = 0; i < lines.length; i++) {
|
|
321
|
+
const line = lines[i]!;
|
|
322
|
+
if (isFenceLine(line)) {
|
|
323
|
+
inFence = !inFence;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (inFence) continue;
|
|
327
|
+
const m = HEADING_RE.exec(line);
|
|
328
|
+
if (m) {
|
|
329
|
+
headings.push({ depth: m[1]!.length, title: m[2]!.trim(), line: i });
|
|
330
|
+
} else if (bannerLine < 0 && SERIES_RE.test(line.trim())) {
|
|
331
|
+
bannerLine = i;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const items: SkeletonItem[] = [];
|
|
336
|
+
const h1 = headings.find((h) => h.depth === 1);
|
|
337
|
+
if (h1) items.push({ kind: "h1", title: h1.title, depth: 1, startLine: h1.line + 1, endLine: h1.line + 1 });
|
|
338
|
+
if (bannerLine >= 0) items.push({ kind: "series", startLine: bannerLine + 1, endLine: bannerLine + 1 });
|
|
339
|
+
if (block) items.push({ kind: "infobox", startLine: block.start + 1, endLine: block.end + 1 });
|
|
340
|
+
|
|
341
|
+
const sections = headings.filter((h) => h.depth === 2);
|
|
342
|
+
const firstSectionLine = sections.length ? sections[0]!.line : lines.length;
|
|
343
|
+
|
|
344
|
+
// Lead: prose after the header region, before the first section.
|
|
345
|
+
let leadStart = h1 ? h1.line + 1 : 0;
|
|
346
|
+
if (bannerLine + 1 > leadStart) leadStart = bannerLine + 1;
|
|
347
|
+
if (block && block.end + 1 > leadStart) leadStart = block.end + 1;
|
|
348
|
+
while (leadStart < firstSectionLine && lines[leadStart]!.trim() === "") leadStart++;
|
|
349
|
+
let leadEnd = firstSectionLine - 1;
|
|
350
|
+
while (leadEnd >= leadStart && lines[leadEnd]!.trim() === "") leadEnd--;
|
|
351
|
+
if (leadEnd >= leadStart) items.push({ kind: "lead", startLine: leadStart + 1, endLine: leadEnd + 1 });
|
|
352
|
+
|
|
353
|
+
for (let s = 0; s < sections.length; s++) {
|
|
354
|
+
const cur = sections[s]!;
|
|
355
|
+
const nextLine = s + 1 < sections.length ? sections[s + 1]!.line : lines.length;
|
|
356
|
+
const norm = cur.title.trim().toLowerCase();
|
|
357
|
+
const kind: SkeletonKind =
|
|
358
|
+
norm === "see also" ? "see-also" : norm === "notes" ? "notes" : norm === "references" ? "references" : "section";
|
|
359
|
+
items.push({ kind, title: cur.title, depth: 2, startLine: cur.line + 1, endLine: nextLine });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
items.sort((a, b) => a.startLine - b.startLine);
|
|
363
|
+
return items;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- footnotes --------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
const MARKER_RE = /\[\^([^\]]+)\](?!:)/g;
|
|
369
|
+
const DEF_RE = /^\[\^([^\]]+)\]:\s?(.*)$/;
|
|
370
|
+
|
|
371
|
+
export interface FootnoteMarker {
|
|
372
|
+
id: string;
|
|
373
|
+
line: number;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface FootnoteDef {
|
|
377
|
+
id: string;
|
|
378
|
+
line: number;
|
|
379
|
+
text: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface Footnotes {
|
|
383
|
+
/** Every inline `[^N]` reference (excludes `[^N]:` definition lines). */
|
|
384
|
+
markers: FootnoteMarker[];
|
|
385
|
+
/** Every `[^N]:` definition. */
|
|
386
|
+
defs: FootnoteDef[];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Parse footnote markers and definitions. NORMALIZE asserts the bijection
|
|
390
|
+
* (every marker ⇔ exactly one def); this just reports what is present so a
|
|
391
|
+
* dangling marker or orphan def is detectable as a set difference. */
|
|
392
|
+
export function parseFootnotes(md: string): Footnotes {
|
|
393
|
+
const lines = md.split(/\r?\n/);
|
|
394
|
+
const markers: FootnoteMarker[] = [];
|
|
395
|
+
const defs: FootnoteDef[] = [];
|
|
396
|
+
let inFence = false;
|
|
397
|
+
for (let i = 0; i < lines.length; i++) {
|
|
398
|
+
const line = lines[i]!;
|
|
399
|
+
if (isFenceLine(line)) {
|
|
400
|
+
inFence = !inFence;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (inFence) continue;
|
|
404
|
+
const dm = DEF_RE.exec(line);
|
|
405
|
+
if (dm) {
|
|
406
|
+
defs.push({ id: dm[1]!.trim(), line: i + 1, text: dm[2]!.trim() });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
MARKER_RE.lastIndex = 0;
|
|
410
|
+
let m: RegExpExecArray | null;
|
|
411
|
+
while ((m = MARKER_RE.exec(line)) !== null) {
|
|
412
|
+
markers.push({ id: m[1]!.trim(), line: i + 1 });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { markers, defs };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---- category declarations -------------------------------------------
|
|
419
|
+
//
|
|
420
|
+
// Categories are EMERGENT and EXPLICIT: an article declares its memberships on a
|
|
421
|
+
// single `categories:` line near the top, listing comma-separated
|
|
422
|
+
// `[[Category:Name]]` wikilinks (P1-T4a). This deriver is the SOLE mechanism —
|
|
423
|
+
// there is NO `type→category` map. A category comes into existence the first
|
|
424
|
+
// time some article declares it; the `categories` table simply accumulates the
|
|
425
|
+
// names seen here on reindex.
|
|
426
|
+
|
|
427
|
+
/** A line that declares category memberships (`categories: [[Category:X]], …`). */
|
|
428
|
+
const CATEGORIES_LINE_RE = /^categories:\s*(.+)$/i;
|
|
429
|
+
/** One `[[Category:Name]]` membership wikilink. */
|
|
430
|
+
const CATEGORY_LINK_RE = /\[\[Category:([^\]|#]+)(?:[|#][^\]]*)?\]\]/g;
|
|
431
|
+
|
|
432
|
+
export interface CategoryRow {
|
|
433
|
+
article_stem: string;
|
|
434
|
+
category: string;
|
|
435
|
+
source: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface DerivedCategories {
|
|
439
|
+
/** One `article_categories` row per declared `[[Category:Name]]`. */
|
|
440
|
+
categoryRows: CategoryRow[];
|
|
441
|
+
/** The distinct category names this article declares, in document order. */
|
|
442
|
+
categories: string[];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Read an article's explicit category declarations into `article_categories`
|
|
447
|
+
* rows. Scans `categories:` lines outside fenced code, collecting each
|
|
448
|
+
* `[[Category:Name]]` membership. Emergent and explicit — never derived from the
|
|
449
|
+
* infobox `type:` label. Returns deduped categories in first-seen order.
|
|
450
|
+
*/
|
|
451
|
+
export function deriveCategories(md: string, stem: string): DerivedCategories {
|
|
452
|
+
const lines = md.split(/\r?\n/);
|
|
453
|
+
const categories: string[] = [];
|
|
454
|
+
const seen = new Set<string>();
|
|
455
|
+
let inFence = false;
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
if (isFenceLine(line)) {
|
|
458
|
+
inFence = !inFence;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (inFence) continue;
|
|
462
|
+
const decl = CATEGORIES_LINE_RE.exec(line.trim());
|
|
463
|
+
if (!decl) continue;
|
|
464
|
+
CATEGORY_LINK_RE.lastIndex = 0;
|
|
465
|
+
let m: RegExpExecArray | null;
|
|
466
|
+
while ((m = CATEGORY_LINK_RE.exec(decl[1]!)) !== null) {
|
|
467
|
+
const name = m[1]!.trim();
|
|
468
|
+
if (name && !seen.has(name)) {
|
|
469
|
+
seen.add(name);
|
|
470
|
+
categories.push(name);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const categoryRows = categories.map((category) => ({ article_stem: stem, category, source: "declared" }));
|
|
475
|
+
return { categoryRows, categories };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- structural validation -------------------------------------------
|
|
479
|
+
|
|
480
|
+
export interface ArticleValidation {
|
|
481
|
+
ok: boolean;
|
|
482
|
+
/** Human-readable grammar violations; empty when `ok`. */
|
|
483
|
+
errors: string[];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
interface ScannedHeading {
|
|
487
|
+
depth: number;
|
|
488
|
+
title: string;
|
|
489
|
+
line: number;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Fence-aware heading scan (the one the validator shares with the grammar). */
|
|
493
|
+
function scanHeadings(lines: string[]): ScannedHeading[] {
|
|
494
|
+
const out: ScannedHeading[] = [];
|
|
495
|
+
let inFence = false;
|
|
496
|
+
for (let i = 0; i < lines.length; i++) {
|
|
497
|
+
const line = lines[i]!;
|
|
498
|
+
if (isFenceLine(line)) {
|
|
499
|
+
inFence = !inFence;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (inFence) continue;
|
|
503
|
+
const m = HEADING_RE.exec(line);
|
|
504
|
+
if (m) out.push({ depth: m[1]!.length, title: m[2]!.trim(), line: i });
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Assert the fixed top-to-bottom grammar (P1-T1): exactly one H1; the infobox
|
|
511
|
+
* (if any) precedes the lead and all sections; `## See also` precedes
|
|
512
|
+
* `## References`; `## References` (if present) is the LAST section. Returns the
|
|
513
|
+
* set of violations so a malformed article is rejectable as `ok === false`.
|
|
514
|
+
*/
|
|
515
|
+
export function validateArticleStructure(md: string): ArticleValidation {
|
|
516
|
+
const lines = md.split(/\r?\n/);
|
|
517
|
+
const errors: string[] = [];
|
|
518
|
+
|
|
519
|
+
// Walk once for infobox-vs-body ordering (fence-aware).
|
|
520
|
+
let inFence = false;
|
|
521
|
+
let sawBody = false;
|
|
522
|
+
let infoboxLine = -1;
|
|
523
|
+
for (let i = 0; i < lines.length; i++) {
|
|
524
|
+
const line = lines[i]!;
|
|
525
|
+
if (isFenceLine(line)) {
|
|
526
|
+
if (!inFence && fenceLabel(line) === "info") {
|
|
527
|
+
if (infoboxLine < 0) infoboxLine = i;
|
|
528
|
+
if (sawBody) errors.push("infobox must precede the lead and all sections");
|
|
529
|
+
}
|
|
530
|
+
inFence = !inFence;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (inFence) continue;
|
|
534
|
+
const t = line.trim();
|
|
535
|
+
if (!t) continue;
|
|
536
|
+
const hm = HEADING_RE.exec(line);
|
|
537
|
+
if (hm) {
|
|
538
|
+
if (hm[1]!.length >= 2) sawBody = true; // a section heading is body
|
|
539
|
+
continue; // the H1 is header region, not body
|
|
540
|
+
}
|
|
541
|
+
if (SERIES_RE.test(t)) continue; // series banner is header region
|
|
542
|
+
if (CATEGORIES_LINE_RE.test(t)) continue; // category declaration is header region
|
|
543
|
+
if (STUB_TEMPLATE_RE.test(t)) continue; // {{stub}} is a template marker, not prose
|
|
544
|
+
sawBody = true; // any other prose is lead/body
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const headings = scanHeadings(lines);
|
|
548
|
+
const h1Count = headings.filter((h) => h.depth === 1).length;
|
|
549
|
+
if (h1Count !== 1) errors.push(`expected exactly one H1, found ${h1Count}`);
|
|
550
|
+
|
|
551
|
+
const sections = headings.filter((h) => h.depth === 2);
|
|
552
|
+
const refIdx = sections.findIndex((s) => s.title.trim().toLowerCase() === "references");
|
|
553
|
+
const seeIdx = sections.findIndex((s) => s.title.trim().toLowerCase() === "see also");
|
|
554
|
+
if (refIdx >= 0 && refIdx !== sections.length - 1) errors.push("## References must be the last section");
|
|
555
|
+
if (seeIdx >= 0 && refIdx >= 0 && seeIdx > refIdx) errors.push("## See also must precede ## References");
|
|
556
|
+
|
|
557
|
+
return { ok: errors.length === 0, errors };
|
|
558
|
+
}
|