Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions scripts/wf1-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// ISC-WF-1 additivity gate (spec §7) — golden capture + zero-delta check.
//
// Runs every row of the frozen golden capture (produced by interscript-rs
// tests/golden/isc-wf0.jsonl: {map, src, out}) through THIS runtime and
// verifies byte-identical output on maps that use none of the WF-1
// constructs (ruleset / use / override / languages / when).
//
// Usage:
// node scripts/wf1-gate.mjs capture <out.jsonl> # write computed outputs
// node scripts/wf1-gate.mjs check <baseline.jsonl> # zero-delta vs a prior capture
// node scripts/wf1-gate.mjs parity # delta count vs the rs golden
// node scripts/wf1-gate.mjs parse # parse the whole corpus
//
// Environment:
// GOLDEN_JSONL path to isc-wf0.jsonl (default: ../interscript-rs/tests/golden/)
// MAPS_CORPUS maps dir (default: ../maps/maps)
// MAPS_LIBS libs dir (default: ../maps/libs)

import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs"
import { basename, dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { createHash } from "node:crypto"

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const mapsDir = process.env.MAPS_CORPUS ?? resolve(repoRoot, "..", "maps", "maps")
const libsDir = process.env.MAPS_LIBS ?? resolve(repoRoot, "..", "maps", "libs")
const goldenPath =
process.env.GOLDEN_JSONL ?? resolve(repoRoot, "..", "interscript-rs", "tests", "golden", "isc-wf0.jsonl")

const [, , mode, arg] = process.argv
if (!mode || (mode !== "parse" && mode !== "parity" && !arg)) {
console.error("usage: wf1-gate.mjs capture|check <file.jsonl> | parity | parse")
process.exit(2)
}

function loadSources() {
const sources = {}
let count = 0
for (const dir of [mapsDir, libsDir]) {
if (!existsSync(dir)) continue
for (const f of readdirSync(dir).filter((f) => f.endsWith(".isc"))) {
const code = basename(f, ".isc")
sources[code] = readFileSync(join(dir, f), "utf8")
count++
}
}
return { sources, count }
}

// The WF-1 construct tokens — the adopting set is any corpus file whose
// source contains them OUTSIDE metadata prose. The gate treats a map as
// adopting if the raw source mentions them at line start (prose lines in
// metadata are consumed as raw text by the parser and never dispatch).
const WF1_TOKENS = /^\s*(ruleset\s|use\s|override\s|languages\s|when\s)/m

function goldenRows() {
if (!existsSync(goldenPath)) {
console.error(`golden not found: ${goldenPath}`)
process.exit(1)
}
return readFileSync(goldenPath, "utf8")
.split("\n")
.filter((l) => l.trim() !== "")
.map((l) => JSON.parse(l))
}

async function run() {
const { parseIsc } = await import(new URL("../src/isc/parser.ts", import.meta.url))
const { iscBundledStrategy } = await import(new URL("../src/isc/loader.ts", import.meta.url))
const { configure, transliterate } = await import(new URL("../src/index.ts", import.meta.url))

const { sources, count } = loadSources()

if (mode === "parse") {
let ok = 0
const failures = []
for (const [code, src] of Object.entries(sources)) {
try {
parseIsc(src, `${code}.isc`)
ok++
} catch (e) {
failures.push(`${code}: ${e.message}`)
}
}
console.log(`parsed ${ok}/${count} corpus files`)
if (failures.length > 0) {
console.log("FAILURES:\n" + failures.join("\n"))
process.exit(1)
}
return
}

configure({ strategies: [iscBundledStrategy(sources)] })
const rows = goldenRows()
const computed = []
let errors = 0
for (const row of rows) {
let out
try {
out = transliterate(row.map, row.src)
} catch (e) {
out = `__ERROR__:${e.message}`
errors++
}
computed.push({ map: row.map, src: row.src, out })
}

if (mode === "parity") {
// Cross-runtime audit against the rs-produced golden (informational:
// pre-existing divergences on origin/main are not gate failures).
let delta = 0
const mismatched = new Set()
for (let i = 0; i < rows.length; i++) {
if (rows[i].out !== computed[i].out) {
delta++
mismatched.add(computed[i].map)
}
}
console.log(
`cross-runtime parity vs rust golden: ${rows.length - delta}/${rows.length} rows match ` +
`(${delta} delta on ${mismatched.size} maps, ${errors} errors)`,
)
if (mismatched.size > 0) console.log(`divergent maps: ${[...mismatched].sort().join(", ")}`)
return
}

if (mode === "capture") {
writeFileSync(
arg,
computed.map((r) => JSON.stringify(r)).join("\n") + "\n",
)
const digest = createHash("sha256")
.update(computed.map((r) => JSON.stringify(r)).join("\n"))
.digest("hex")
console.log(
`captured ${computed.length} rows (${errors} errors) -> ${arg}\ndigest: ${digest}`,
)
return
}

// check mode: compare a fresh capture against a previously captured
// baseline of THIS runtime (the additivity gate — zero delta required).
// Use `capture` + `diff` against interscript-rs' golden to audit
// cross-runtime parity separately.
let baseline
try {
baseline = readFileSync(arg, "utf8")
.split("\n")
.filter((l) => l.trim() !== "")
.map((l) => JSON.parse(l))
} catch {
console.error(`cannot read baseline capture: ${arg}`)
process.exit(1)
}
if (baseline.length !== computed.length) {
console.error(`row count mismatch: baseline ${baseline.length} vs computed ${computed.length}`)
process.exit(1)
}
let delta = 0
const samples = []
for (let i = 0; i < computed.length; i++) {
const b = baseline[i]
const c = computed[i]
if (b.map !== c.map || b.src !== c.src) {
console.error(`row ${i}: map/src mismatch against baseline`)
process.exit(1)
}
if (b.out !== c.out) {
delta++
if (samples.length < 10) {
samples.push(
`${c.map} "${c.src.slice(0, 24)}": baseline "${b.out.slice(0, 40)}" vs got "${c.out.slice(0, 40)}"`,
)
}
}
}
const digest = createHash("sha256")
.update(computed.map((r) => JSON.stringify(r)).join("\n"))
.digest("hex")
console.log(
`checked ${computed.length} rows against baseline: ${delta} delta, ${errors} errors\ndigest: ${digest}`,
)
if (samples.length > 0) console.log("sample deltas:\n" + samples.join("\n"))
process.exit(delta === 0 ? 0 : 1)
}

run().catch((e) => {
console.error(e)
process.exit(1)
})
10 changes: 7 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ async function cmdTransliterate(args: string[], opts: GlobalOpts): Promise<numbe
options: {
input: { type: "string", short: "i" },
output: { type: "string", short: "o" },
// ISC-WF-1 W3 — select a declared language for language-conditioned maps.
language: { type: "string" },
},
allowPositionals: true,
strict: true,
Expand All @@ -140,8 +142,8 @@ async function cmdTransliterate(args: string[], opts: GlobalOpts): Promise<numbe
!existsSync(resolve(process.cwd(), "maps")) &&
!existsSync(resolve(process.cwd(), "public/maps")))
const result = useAsync
? await transliterateAsync(systemCode, inputText)
: transliterate(systemCode, inputText)
? await transliterateAsync(systemCode, inputText, undefined, values.language)
: transliterate(systemCode, inputText, undefined, values.language)
if (values.output) {
writeFileSync(resolve(process.cwd(), values.output), result + "\n")
} else {
Expand All @@ -160,6 +162,7 @@ async function cmdBatch(args: string[], opts: GlobalOpts): Promise<number> {
options: {
output: { type: "string", short: "o" },
csv: { type: "boolean", default: false },
language: { type: "string" },
},
allowPositionals: true,
strict: true,
Expand All @@ -185,7 +188,7 @@ async function cmdBatch(args: string[], opts: GlobalOpts): Promise<number> {
const rows: Array<{ input: string; output: string; error?: string }> = []
for (const input of lines) {
try {
const output = await transliterateAsync(systemCode, input)
const output = await transliterateAsync(systemCode, input, undefined, values.language)
rows.push({ input, output })
} catch (e) {
rows.push({ input, output: "", error: (e as Error).message })
Expand Down Expand Up @@ -303,6 +306,7 @@ Usage:

Commands:
transliterate <systemCode> [-i FILE] [-o FILE] Transliterate text (alias: t)
[--language <code>] Select a declared language (WF-1)
batch <systemCode> <inputFile> [--csv] [-o FILE] Bulk process many lines (alias: b)
list [--authority X] [--source-script X] List available systems (alias: l)
detect <input> <output> Find best-matching system (alias: d)
Expand Down
36 changes: 27 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,16 @@ export type {
IscDocument,
IscItem,
IscRule,
IscRuleset,
IscStage,
IscStageItem,
IscTest,
IscConstraint,
} from "./isc/types.js"
export { parseIsc } from "./isc/parser.js"
export { IscParseError } from "./isc/types.js"
export { iscToCompiledMap } from "./isc/converter.js"
export { IscParseError, IscLoadError } from "./isc/types.js"
export { iscToCompiledMap, type IscToCompiledMapOptions } from "./isc/converter.js"
export { expandDocument, requiredDependencyDocs, type DependencyDocResolver } from "./isc/expand.js"
export { setRababaConfig, resetRababaConfigs, type RababaConfigEntry } from "./stdlib/ml.js"

export interface InterscriptConfig {
Expand Down Expand Up @@ -149,12 +151,17 @@ class InterscriptRuntime {
/**
* Transliterate `input` using `systemCode`. Loads the map on first use,
* caches it.
*
* `language` (ISC-WF-1 W3): selects one of the map's declared
* languages. When given and declared, `when`-guarded rules listing it
* apply and all other guarded rules are inert; when omitted (or
* undeclared), every guarded rule is inert.
*/
transliterate(systemCode: SystemCode, input: string, stage?: string): string {
transliterate(systemCode: SystemCode, input: string, stage?: string, language?: string): string {
try {
const map = this.loadMap(systemCode)
const stageName = stage ?? this.defaultStage
return executeStage(map, stageName, input, this.loader)
return executeStage(map, stageName, input, this.loader, language)
} catch (e) {
if (e instanceof InterscriptError) throw e
throw new SystemConversionError(
Expand All @@ -170,11 +177,16 @@ class InterscriptRuntime {
* Also handles ML-powered maps (rababa, secryst) — use this instead
* of transliterate() for any map that might contain ML funcalls.
*/
async transliterateAsync(systemCode: SystemCode, input: string, stage?: string): Promise<string> {
async transliterateAsync(
systemCode: SystemCode,
input: string,
stage?: string,
language?: string,
): Promise<string> {
try {
const map = await this.loadMapAsync(systemCode)
const stageName = stage ?? this.defaultStage
return await executeStageAsync(map, stageName, input, this.loader)
return await executeStageAsync(map, stageName, input, this.loader, language)
} catch (e) {
if (e instanceof InterscriptError) throw e
throw new SystemConversionError(
Expand Down Expand Up @@ -236,8 +248,13 @@ function runtime(): InterscriptRuntime {
}

/** Public API — mirrors Interscript.transliterate from Ruby. */
export function transliterate(systemCode: SystemCode, input: string, stage?: string): string {
return runtime().transliterate(systemCode, input, stage)
export function transliterate(
systemCode: SystemCode,
input: string,
stage?: string,
language?: string,
): string {
return runtime().transliterate(systemCode, input, stage, language)
}

/**
Expand All @@ -248,8 +265,9 @@ export function transliterateAsync(
systemCode: SystemCode,
input: string,
stage?: string,
language?: string,
): Promise<string> {
return runtime().transliterateAsync(systemCode, input, stage)
return runtime().transliterateAsync(systemCode, input, stage, language)
}

/** Public API — mirrors Interscript.load. */
Expand Down
Loading
Loading