第六、七轮:离线 ort/WebGPU、R9 查看器整周期、安全层截止下沉、enc_once 前瞻 bug(feat 动态轴)、UTF-8、断点 RNG 续跑与 --stop-at、R2 池无关性自检、R8 出题缓存、本机自检 selftest、冒烟 smoke;遗留清单并入审查文档第六节

This commit is contained in:
Hark
2026-10-17 20:28:00 +00:00
parent e6388b2f0c
commit fd2f2db991
29 changed files with 715 additions and 127 deletions
+46 -4
View File
@@ -6,6 +6,8 @@
* &threads=N wasm 线程数(默认 min(16, ⌊0.6×逻辑核⌋);2026-10-18 R4 实测 28 核机 16 最优、24 反而变慢、4 线程 P99 37 ms)
* &proxy=0 关闭推理代理 Worker(默认开:wasm 推理不占主线程,见下)
* &probe=1 允许加载 --random 导出的延迟探针模型(默认拒绝,防把随机策略当真模型跑)
* &autotest=N 自动跑 N 道贴立柱题(与 tests/policy_bench.cjs 同种子同题),结果 POST 到 &report=(默认 /r4/result);
* 由 python -m ml.eval.r4_probe --viewer 驱动,测部署形态的整次规划耗时与节拍(方案 R9)
* 2026-10-18 第五轮:ort.env.wasm.proxy=true 把 wasm 推理挪到 ort 自带的 Worker。此前推理在主线程同步算,
* R4 实测时 CDP 15 s 拿不到执行槽,50 Hz tick 也被它拖住——丢步不只来自推理时长本身。
* 目标变化(松手求解 / 实时追踪)都交给 ArmPolicy.Runner:50 Hz 执行时钟放在 Web Worker(不受 rAF 60 Hz 与后台节流影响),
@@ -18,7 +20,10 @@
const qs = new URLSearchParams(location.search);
if (!qs.has('policy')) return;
const MODEL = qs.get('policy') && qs.get('policy') !== '1' ? qs.get('policy') : 'models/traj_policy.onnx';
const ORT_BASE = qs.get('ortLocal') === '1' ? 'vendor/ort/' : 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
/* 2026-10-18 第六轮(审查 A-3):本地路径必须是绝对 URL——ORT 用 import() 加载 .mjs,相对前缀 'vendor/ort/' 会被当成裸模块名而失败 */
const ORT_BASE = qs.get('ortLocal') === '1' ? new URL('vendor/ort/', location.href).href : 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
/* WebGPU 后端只在 ort.webgpu.min.js 里(ort.min.js 只有 wasm,第六轮实测 "[webgpu] backend not found" 即此因) */
const ORT_JS = qs.get('ep') === 'webgpu' ? 'ort.webgpu.min.js' : 'ort.min.js';
const badge = () => document.getElementById('ikBadge');
const say = (t, cls) => { const b = badge(); if (b) { b.textContent = t; b.className = 'badge ' + (cls || 'wait'); } console.log('[policy]', t); };
let ready = false, runner = null, running = false, worker = null;
@@ -37,7 +42,7 @@ async function boot() {
if (meta.tcp0 && meta.tcp0.some((v, i) => Math.abs(v - S.TCP0[i]) > 0.01)) throw new Error('TCP 与训练时不同:' + S.TCP0 + ' vs ' + meta.tcp0);
const probe = !!meta.random_init;
if (probe && qs.get('probe') !== '1') throw new Error('这是随机初始化的延迟探针模型(meta.random_init),不是训练产物;仅测延迟时加 &probe=1');
if (!window.ort) await loadScript(ORT_BASE + 'ort.min.js');
if (!window.ort) await loadScript(ORT_BASE + ORT_JS);
ort.env.wasm.wasmPaths = ORT_BASE;
const hc = navigator.hardwareConcurrency || 1;
ort.env.wasm.numThreads = !self.crossOriginIsolated ? 1 : (+qs.get('threads') || Math.max(1, Math.min(16, Math.floor(hc * 0.6))));
@@ -49,7 +54,7 @@ async function boot() {
catch (e) { console.warn('[policy] 代理 Worker 不可用,退回主线程推理:', e); proxy = false; ort.env.wasm.proxy = false; sess = null; }
}
if (!sess) sess = await ort.InferenceSession.create(MODEL, { executionProviders: ep, graphOptimizationLevel: 'all' });
const infer = ArmPolicy.ortInfer(ort, sess, { encOnce: !!meta.enc_once });
const infer = ArmPolicy.ortInfer(ort, sess, { encOnce: !!meta.enc_once, featDyn: !!meta.feat_dyn });
runner = new ArmPolicy.Runner(S, infer, {});
/* 预热:首次推理有编译开销 */
await infer(new Float32Array(8 * ArmPolicy.F_DIM), new Float32Array(8 * ArmPolicy.H * 6), 8);
@@ -65,18 +70,55 @@ async function boot() {
},
stop, stats: () => runner.stats, runner: () => runner,
latency: () => { const p = (a, x) => { const s = a.slice().sort((u, v) => u - v); return s.length ? s[Math.min(s.length - 1, Math.floor(s.length * x))] : null; };
return { inferP50: p(runner.stats.inferMs, .5), inferP99: p(runner.stats.inferMs, .99), safetyP50: p(runner.stats.safetyMs, .5), safetyP99: p(runner.stats.safetyMs, .99), threads: ort.env.wasm.numThreads, ep: ep[0], proxy, encOnce: !!meta.enc_once, probe }; }
const st = runner.stats;
return { inferP50: p(st.inferMs, .5), inferP99: p(st.inferMs, .99), safetyP50: p(st.safetyMs, .5), safetyP99: p(st.safetyMs, .99),
lookaheadP99: p(st.lookaheadMs, .99), planP50: p(st.planMs, .5), planP99: p(st.planMs, .99), certQP99: p(st.certQ, .99),
tickGapP99: p(TICKS.gaps, .99), tickGapMax: TICKS.gaps.length ? Math.max(...TICKS.gaps) : null, starve: TICKS.starve, ticks: TICKS.n,
threads: ort.env.wasm.numThreads, ep: ep[0], proxy, encOnce: !!meta.enc_once, featDyn: !!meta.feat_dyn, probe }; }
};
ready = true;
const how = ep[0] + ',' + ort.env.wasm.numThreads + ' 线程' + (proxy ? ',推理在 Worker' : ',推理在主线程');
if (probe) say('随机探针模型(仅测延迟,动作无意义):' + how, 'badb');
else say('策略模式就绪(' + how + '):拖动目标即由策略执行', 'ok');
if (qs.get('autotest')) autotest(+qs.get('autotest') || 30, meta);
} catch (e) { say('策略模式不可用:' + e.message, 'badb'); console.error(e); }
}
/* 节拍统计(R9):tick 间隔、饥饿次数(执行时钟到了但队列为空、又没到达 = 实际丢步) */
const TICKS = { gaps: [], last: 0, starve: 0, n: 0 };
/* autotest:同一批贴立柱题(PolicyCases.ncCases,与 Node bench 同种子 20261017)逐题跑,每题限时 10 s,结果 POST 回驱动方 */
async function autotest(N, meta) {
const S = window.IK.solver;
if (!window.PolicyCases) await loadScript(new URL('js/policy-cases.js', location.href).href);
const cases = PolicyCases.ncCases(S, N, 20261017), per = [];
const t0 = performance.now();
for (let i = 0; i < cases.length; i++) {
const c = cases[i]; stop(); await new Promise(r => setTimeout(r, 50));
window.IK.setJoints(c.q0, false); runner.reset(c.q0); runner.setGoal(c.T, null);
const s0 = { plans: runner.stats.plans, brakes: runner.stats.brakes }, ts = performance.now(); let col = 0;
TICKS.last = 0; start();
while (running && performance.now() - ts < 10000) {
await new Promise(r => setTimeout(r, 100));
if (runner.q && !S.collideFree(runner.q)) col++;
}
stop();
const { e } = runner.err(runner.q);
per.push({ i, reached: runner.done && e < 1, err: +e.toFixed(1), colSamples: col, plans: runner.stats.plans - s0.plans, brakes: runner.stats.brakes - s0.brakes, s: +((performance.now() - ts) / 1000).toFixed(2) });
}
const lat = window.__POLICY.latency();
const res = { kind: 'viewer_autotest', model: MODEL, probe: !!meta.random_init, n: cases.length, reached: per.filter(x => x.reached).length,
collisionTasks: per.filter(x => x.colSamples).length, brakes: runner.stats.brakes, plans: runner.stats.plans, certMemoHits: runner.stats.certHit,
certFailWhy: runner.stats.certWhy, wallS: +((performance.now() - t0) / 1000).toFixed(1), coi: self.crossOriginIsolated, ua: navigator.userAgent, ...lat, per };
say('autotest 完成:到达 ' + res.reached + '/' + res.n + ',整次规划 P99 ' + res.planP99 + ' ms', 'ok');
try { await fetch(qs.get('report') || '/r4/result', { method: 'POST', body: JSON.stringify(res) }); } catch (e) { console.warn(e); }
window.__POLICY.autotest = res;
}
function start() { if (running) return; running = true; worker.postMessage(ArmPolicy.DT * 1000); }
function stop() { running = false; if (worker) worker.postMessage(0); }
function onTick() {
if (!running) return;
const tn = performance.now(); if (TICKS.last) TICKS.gaps.push(tn - TICKS.last); TICKS.last = tn; TICKS.n++;
if (TICKS.gaps.length > 20000) TICKS.gaps.splice(0, 10000);
if (!runner.queue.length && !runner.done && runner.goal) TICKS.starve++;
if (runner.needPlan()) runner.plan().catch(e => { console.error(e); stop(); say('策略推理出错:' + e.message, 'badb'); });
const q = runner.tick();
if (q) window.IK.setJoints(q, false);
+55 -23
View File
@@ -59,12 +59,14 @@ function makeCert(S, opt) {
const L = S.LIM, O = S.ORDER;
const memo = new Map();
const key = q => q.map(v => v.toFixed(5)).join(',');
let queries = 0, budgetLeft = Infinity;
const gm = q => { const k = key(q); let M = memo.get(k); if (!M) { M = S.gapMatrix(q, CAP); queries++; budgetLeft--; if (memo.size > 50000) memo.clear(); memo.set(k, M); } return M; };
let queries = 0, hits = 0, budgetLeft = Infinity, deadline = Infinity;
const clock = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
const gm = q => { const k = key(q); let M = memo.get(k); if (!M) { M = S.gapMatrix(q, CAP); queries++; budgetLeft--; if (memo.size > 50000) memo.clear(); memo.set(k, M); } else hits++; return M; };
const minGap = M => { let m = Infinity; for (let a = 0; a < 7; a++) for (let b = a + 2; b < 7; b++) m = Math.min(m, M[a * 7 + b]); return m; };
const inBox = q => O.every((id, i) => L[id].cont || (q[i] >= L[id].lo - 1e-6 && q[i] <= L[id].hi + 1e-6));
function seg(a, b, depth) {
if (budgetLeft <= 0) return false;
if (deadline !== Infinity && clock() > deadline) { budgetLeft = 0; return false; } // 超时与超预算同样判不过(→ 刹停,保守)
const Ma = gm(a), Mb = gm(b);
const dq = a.map((v, i) => Math.abs(b[i] - v));
let ok = true;
@@ -79,19 +81,22 @@ function makeCert(S, opt) {
const m = a.map((v, i) => 0.5 * (v + b[i]));
return seg(a, m, depth + 1) && seg(m, b, depth + 1);
}
/* 折线(相邻点按短弧展开)→ {ok, queries, minGap, failAt, why};budget = 本次最多新增距离查询数(超出判不过,why='budget') */
function path(pts, budget) {
/* 折线(相邻点按短弧展开)→ {ok, queries, minGap, failAt, why};budget = 本次最多新增距离查询数(超出判不过,why='budget')
2026-10-18 第六轮(审查 B-2):可选 until = 墙钟截止时刻(performance.now 口径),到点即判不过。
此前 Runner 只在两条候选之间看 30 ms 预算,单条候选内部 40 次查询 × 8 条可把安全层拖到 250 ms。 */
function path(pts, budget, until) {
const q0 = queries; let mg = Infinity;
budgetLeft = budget || Infinity;
budgetLeft = budget || Infinity; deadline = until || Infinity;
const P = [pts[0].slice()];
for (let i = 1; i < pts.length; i++) P.push(P[i - 1].map((v, k) => L[O[k]].cont ? v + wrap180(pts[i][k] - v) : pts[i][k]));
for (let i = 0; i < P.length; i++) { if (!inBox(P[i])) return { ok: false, queries: queries - q0, minGap: mg, failAt: i, why: 'box' }; }
for (let i = 0; i < P.length; i++) mg = Math.min(mg, minGap(gm(P[i])));
for (let i = 1; i < P.length; i++) if (!seg(P[i - 1], P[i], 0)) return { ok: false, queries: queries - q0, minGap: mg, failAt: i, why: budgetLeft <= 0 ? 'budget' : 'cert' };
budgetLeft = Infinity;
for (let i = 1; i < P.length; i++) if (!seg(P[i - 1], P[i], 0)) { const why = deadline !== Infinity && clock() > deadline ? 'time' : budgetLeft <= 0 ? 'budget' : 'cert'; budgetLeft = Infinity; deadline = Infinity; return { ok: false, queries: queries - q0, minGap: mg, failAt: i, why }; }
budgetLeft = Infinity; deadline = Infinity;
return { ok: true, queries: queries - q0, minGap: mg, unwrapped: P };
}
return { seg: (a, b) => { budgetLeft = Infinity; return seg(a, b, 0); }, path, leverTable: TL, gapMin: q => minGap(gm(q)), gm, clear: () => memo.clear() };
return { seg: (a, b) => { budgetLeft = Infinity; deadline = Infinity; return seg(a, b, 0); }, path, leverTable: TL, gapMin: q => minGap(gm(q)), gm, clear: () => memo.clear(),
counters: () => ({ queries, hits }) };
}
/* ---------------- 刹停段:从 q 以每步增量 v 起,按 ACC 均匀减速到 0 ---------------- */
@@ -174,10 +179,12 @@ class Runner {
this.o = Object.assign({ K: 8, commit: 3, margin: 5, /* 2026-10-18:运行时投影余量保持 5 mm(真机护栏,不迁就数据分布);数据侧另行过滤认证间隙 < 3 mm 的老师轨迹,APG 训练余量 3 mm */
projMax: 0.5, refineMm: 5, arriveMm: 1, arriveDeg: 0.5,
stuckPlans: 5, lockSec: 1.0, subReachDeg: 5, subTimeout: 1.5, seed: 12345, lookahead: true,
certBudget: 40, safetyMs: 30 }, opts || {}); // 每条候选最多 40 次距离查询;每次推理安全层总预算 30 ms(超出按刹停处理)
certBudget: 40, safetyMs: 30, firstMs: 45 }, opts || {}); // 每条候选最多 40 次距离查询;安全层总预算 30 ms,第 1 条候选可用到 45 ms(超出按刹停处理)
this.cert = makeCert(S, { leverTable: this.o.leverTable, cap: 20 });
this.rng = makeRng(this.o.seed);
this.stats = { plans: 0, inferMs: [], safetyMs: [], brakes: 0, refine: 0, lookahead: 0, locks: 0, projected: 0 };
/* 2026-10-18 第六轮(审查 B-1/B-2):耗时拆开记——inferMs 主推理、lookaheadMs 前瞻(第二次推理 + 8 份特征)、safetyMs 只含认证、
planMs 整次规划(R9 口径:必须 < 60 ms 周期);certQ 每次规划新增的距离查询数,certHit 记忆命中数,certWhy 认证失败原因计数 */
this.stats = { plans: 0, inferMs: [], lookaheadMs: [], safetyMs: [], planMs: [], certQ: [], certHit: 0, certWhy: {}, brakes: 0, refine: 0, lookahead: 0, locks: 0, projected: 0 };
this.reset(null);
}
reset(q0) {
@@ -224,13 +231,19 @@ class Runner {
const tail = brakeSteps(steps.length ? steps[steps.length - 1] : [0, 0, 0, 0, 0, 0]);
/* 刹停段各步同向等比减速 → 关节空间里是一条直线,认证终点一段即可 */
if (tail.length) { for (const d of tail) q = q.map((v, j) => v + d[j]); pts.push(q.slice()); }
const c = this.cert.path(pts, this.o.certBudget);
const c = this.cert.path(pts, this.o.certBudget, this._until);
if (!c.ok) this.stats.certWhy[c.why || 'collide'] = (this.stats.certWhy[c.why || 'collide'] || 0) + 1;
return { ok: c.ok, tail };
}
async plan() {
if (!this.needPlan()) return;
this.planning = true;
try { await this._plan(); } finally { this.planning = false; }
const t = now(), c0 = this.cert.counters();
try { await this._plan(); } finally {
this.planning = false; this._until = undefined;
const c1 = this.cert.counters();
this.stats.planMs.push(now() - t); this.stats.certQ.push(c1.queries - c0.queries); this.stats.certHit += c1.hits - c0.hits;
}
}
async _plan() {
const S = this.S, o = this.o, K = o.K;
@@ -258,9 +271,9 @@ class Runner {
for (let k = 0; k < K; k++) feat.set(f, k * F_DIM);
for (let i = 0; i < z.length; i++) z[i] = this.rng.n();
const t0 = now();
const out = await this.infer(feat, z, K);
const out = await this.infer(feat, z, K, true); // true:K 行特征相同,enc_once 模型只送 1 行
this.stats.inferMs.push(now() - t0);
const t1 = now();
let t1 = now();
/* 子目标:新目标/跳变后取价值最好的那条输出 */
let best = 0; for (let k = 1; k < K; k++) if (out.value[k] < out.value[best]) best = k;
if (this.needSub) {
@@ -303,8 +316,10 @@ class Runner {
}
let pool = cands;
if (this.lock && this.t < this.lock.until) { const f2 = cands.filter(c => Math.abs(c.j1) < 2 || Math.sign(c.j1) === this.lock.side); if (f2.length) pool = f2; }
/* 短前瞻:冷启动 / 卡死时,各候选块末态再推一块,用其价值打分 */
if (o.lookahead && (this.cold || stuck)) {
/* 短前瞻:冷启动(新目标 / 目标跳变)时,各候选块末态再推一块,用其价值打分。
2026-10-18 第六轮:去掉"卡死时也前瞻"——与仿真 ml/sim/closed_loop.py(只在首块前瞻)对齐;
前瞻要算 8 份特征(8 次 gapMatrix cap40,沙箱 Node 实测 P99 126 ms),卡死时再做会让整次规划连续超周期。卡死由换向锁处理。 */
if (o.lookahead && this.cold) {
this.stats.lookahead++;
const feat2 = new Float32Array(pool.length * F_DIM), z2 = new Float32Array(pool.length * H * 6);
pool.forEach((c, i) => {
@@ -312,17 +327,24 @@ class Runner {
feat2.set(features(S, { q: qa, qPrev: qb, T: this.goal.T, R: this.goal.R, prefix: [], sub: null }), i * F_DIM);
});
for (let i = 0; i < z2.length; i++) z2[i] = this.rng.n();
const o2 = await this.infer(feat2, z2, pool.length);
const o2 = await this.infer(feat2, z2, pool.length, false); // 各行特征不同:必须逐行编码(第六轮修:此前 enc_once 下只送了第 1 行,前瞻价值全错)
pool.forEach((c, i) => { c.score = -2.0 * o2.value[i] - 0.5 * c.value; });
this.stats.lookaheadMs.push(now() - t1); t1 = now();
}
this.cold = false;
pool.sort((a, b) => b.score - a.score);
/* 安全层:依次认证,第一个通过即提交 */
/* 预算(第六轮,审查 B-2):浏览器实测安全层 P99 249 ms。拆开量(沙箱 Node,每次调用):gapMatrix cap20 3.6 ms / cap40 5.5 ms、
collideFree 1.5 ms、criticalPairs 3.1 ms,推理本身 1.5 ms——耗时在几何查询,不在模型。此前截止只在候选之间检查,
单条候选最多 40 次查询(≈150 ms)不受限。现在墙钟截止下沉到认证内部:第 1 条候选截止 t1+firstMs,其余共享到 t1+safetyMs;
到点判不过 → 沿上次已认证的刹停段停,安全性不变,只是可能多刹停。 */
let picked = null;
for (const c of pool) {
if (now() - t1 > o.safetyMs && picked === null && c !== pool[0]) break;
if (c !== pool[0] && now() - t1 > o.safetyMs) break;
this._until = t1 + (c === pool[0] ? Math.max(o.firstMs, o.safetyMs) : o.safetyMs);
const v = this._certCommit(qe, c.steps); if (v.ok) { picked = c; this.brakeTail = v.tail; break; }
}
this._until = undefined;
this.stats.safetyMs.push(now() - t1);
if (picked) {
this.queue.push(...picked.steps); this.lastPlan = [qe.slice()].concat(picked.fut); this.brakeRun = 0;
@@ -338,12 +360,22 @@ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date
/* ---------------- onnxruntime 适配(web: ort = window.ort;node: require('onnxruntime-node')) ---------------- */
/* 2026-10-18 第五轮:模型 meta.enc_once=true(编码一次、解码 K 次)时只送第 1 行特征 [1,F];
Runner 仍按 K 行组织特征(K 行本就相同),旧模型(enc_once 缺省)照旧送 [B,F]。 */
/* 2026-10-18 第六轮:第 4 个参数 shared 表示 K 行特征相同(主推理)。enc_once 模型的 feat 轴是动态的(1 或 K 行):
shared → 送 1 行(编码一次);前瞻各行不同 → 送 B 行逐行编码。 */
function ortInfer(ort, session, opt) {
const encOnce = !!(opt && opt.encOnce);
return async (feat, z, B) => {
const ft = encOnce ? new ort.Tensor('float32', feat.subarray(0, F_DIM), [1, F_DIM]) : new ort.Tensor('float32', feat, [B, F_DIM]);
const r = await session.run({ feat: ft, z: new ort.Tensor('float32', z, [B, H, 6]) });
return { act: r.act.data, value: r.value.data, sub: r.sub.data };
const encOnce = !!(opt && opt.encOnce), featDyn = !!(opt && opt.featDyn); // featDyn:meta.feat_dyn(第六轮导出),feat 轴可为 1 或 K
const run = async (ft, zz, B) => { const r = await session.run({ feat: ft, z: new ort.Tensor('float32', zz, [B, H, 6]) }); return { act: r.act.data, value: r.value.data, sub: r.sub.data }; };
return async (feat, z, B, shared) => {
if (!encOnce) return run(new ort.Tensor('float32', feat.slice(0, B * F_DIM), [B, F_DIM]), z, B);
if (shared !== false) return run(new ort.Tensor('float32', feat.slice(0, F_DIM), [1, F_DIM]), z, B);
if (featDyn) return run(new ort.Tensor('float32', feat.slice(0, B * F_DIM), [B, F_DIM]), z, B);
/* 第五轮导出的 enc_once 模型 feat 轴固定为 1:前瞻逐行各推一次(正确但慢,重新导出即可) */
const act = new Float32Array(B * H * 6), value = new Float32Array(B), sub = new Float32Array(B * 13);
for (let i = 0; i < B; i++) {
const r = await run(new ort.Tensor('float32', feat.slice(i * F_DIM, (i + 1) * F_DIM), [1, F_DIM]), z.slice(i * H * 6, (i + 1) * H * 6), 1);
act.set(r.act, i * H * 6); value[i] = r.value[0]; sub.set(r.sub, i * 13);
}
return { act, value, sub };
};
}
+32
View File
@@ -0,0 +1,32 @@
/* ============================================================================
* policy-cases.js —— 评测题目生成(Node 与浏览器共用,UMD)
* 2026-10-18 第六轮(审查 B-2):贴立柱题从 tests/policy_bench.cjs 移到这里,查看器 ?policy=…&autotest=N
* 与 Node bench 用同一个种子出同一批题,安全层耗时两边可以逐题对照(浏览器 vs Node 差 > 3× 要解释)。
* 算法与原 bench 逐字一致:同种子出的题与第五轮之前相同。
* ==========================================================================*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.PolicyCases = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
function lcg(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
/* 贴立柱任务:终点贴立柱(±4° 扰动即碰立柱)、起点到终点直线被挡;返回 [{q0, T, R}] */
function ncCases(S, N, seed) {
const L = S.LIM, O = S.ORDER, w = S.wrap180, rnd = lcg(seed);
const rq = () => O.map(id => { const t = L[id]; return t.lo + rnd() * (t.hi - t.lo); });
const d = (a, b) => a.map((v, i) => L[O[i]].cont ? w(b[i] - v) : b[i] - v);
const segOK = (a, b) => { const dd = d(a, b); const m = Math.max(...dd.map(Math.abs)); const n = Math.max(1, Math.ceil(m)); for (let i = 1; i <= n; i++) if (!S.collideFree(a.map((v, k) => v + dd[k] * i / n))) return false; return true; };
const hitsColumn = q => (S.collideDetail(q) || []).some(p => /立柱/.test(p.cnA + p.cnB));
const nearColumn = q => { for (const k of [0, 1, 2]) for (const sg of [-4, 4]) { const p = q.slice(); p[k] += sg; if (hitsColumn(p)) return true; } return false; };
const cases = []; let tries = 0;
while (cases.length < N && tries < 4000000) {
tries++;
const b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue;
let a = null;
for (let t = 0; t < 200; t++) { const c = rq(); if (S.collideFree(c) && !segOK(c, b)) { a = c; break; } }
if (a) cases.push({ q0: a, T: S.fk(b, 0), R: S.orient(b) });
}
return cases;
}
return { lcg, ncCases };
}));