From 833225d1690995ef24103a4d7e27e3de0e5790e2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 22:08:32 +0700 Subject: [PATCH] =?UTF-8?q?fix(optimizer):=20=D0=BE=D1=82=D1=81=D0=B5?= =?UTF-8?q?=D1=87=D1=8C=20=D0=B7=D0=B0=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=20=D1=85=D1=83=D0=B4=D1=88=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-performance.md | 9 ++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 2 +- docs/ux-ui-stage-12.md | 9 ++++ lib/route-optimizer-assignment.test.ts | 53 +++++++++++++++++++ lib/route-optimizer-data.ts | 24 +++++++++ .../performance/optimizer-probes-profile.mjs | 11 +++- 7 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 lib/route-optimizer-assignment.test.ts diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 02d46899..181045fd 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -133,3 +133,12 @@ commRecs строятся через filter(sourceServerId).slice(0,24), а Home WAN1000×JH1:1000→50rows,28019→1419tableDOM,28712→2149pageDOM,2264ms dev-ready. WAN10×JH100:103→8columns,9803→683tableDOM,10595→1492pageDOM,1785ms dev-ready. Полные исходные массивы остаются в памяти. Проверены12browser + повтор2 после lint-правки, TypeScript без incremental, targeted ESLint, production build, ReUI validate_usage/checklist. Мобильные снимки docs/ux-ui/screens/optimizer-wan-matrix-{1000,100jh}-320.png просмотрены. Поиск последних объектов, последний диапазон, cache/refresh/clamp,320px/pinnedWAN и WCAG axe приняты. Общая регрессия после delta ещё требуется.6 manualPagination:true остаются; CPU DFS и срез24 commRecs не исправлены этим блоком. + + +## 12.3.61 — точное отсечение перебора проб + +2026-10-04. DFS сохранил порядок WAN/pool, обязательный выбор совместимой пробы и первое назначение при равных totals. Отсекаются только ветви, чья верхняя граница не превосходит найденного bestSum. Две релаксации: сумма независимых максимумов remainingWAN и сумма наибольших remainingCount максимумов уникальных probe IDs. Каждое допустимое завершение ограничено обеими оценками; минимум также верхняя граница. Никакого изменения правил scores, fallback greedy для >7WAN/недостаточного pool, выбора интерфейсов, API или сетевых операций. + +Независимый старый полный DFS в тесте совпал по exact probe IDs на1200 seeded fixtures с ties/incompatible/partial matching. Дополнительно equal20 сохраняет первые7 и проверен forced early choice.7 pure tests/type/lint,6 main browser и8 context прошли. Скрипт с child timeout10с теперь проверяет probe IDs: equal17.3ms(p0..6), varied16.6ms(p13..19), freshness60.9ms(p13..19). До правки equal превышал10с; первая слабая граница оставляла freshness2269ms, усиленная граница сократила его. Время child startup отдельно335–443ms, в расчёт не входит. Production build проходит. + +Это профили7WAN×20, а не гарантия для любого размера/набора: DFS всё ещё экспоненциальный в худшем случае, исходный pool не ограничен; large mixed-interface/live нагрузка остаётся предметом production измерения. Следующий12.3.62 — скрытый срез24commRecs. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 0121c708..68d9e677 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.60 завершён: WAN matrix50 rows/5JH, полный поиск и best, обновление/clamp/320px/axe. Следующий12.3.61: ускорить DFS назначения проб с доказательством сохранения выбора и tie-порядка; CPU7WAN×20 пока timeout10с. Общая регрессия после delta и внешняя12.5 остаются. +Актуальное продолжение 2026-10-04: 12.3.61 завершён: точное отсечение DFS,1200 equivalence fixtures, CPU7WAN×20 equal/varied/freshness без timeout. Следующий12.3.62: профиль и устранение скрытого среза24 Community с bounded grid. Общая регрессия после delta и внешняя12.5 остаются. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 331683f6..fa9dc3ff 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.60 завершён: WAN matrix50 rows/5JH, полный поиск и best, обновление/clamp/320px/axe. Следующий12.3.61: ускорить DFS назначения проб с доказательством сохранения выбора и tie-порядка; CPU7WAN×20 пока timeout10с. Общая регрессия после delta и внешняя12.5 остаются. +Актуальное продолжение 2026-10-04: 12.3.61 завершён: точное отсечение DFS,1200 equivalence fixtures, CPU7WAN×20 equal/varied/freshness без timeout. Следующий12.3.62: профиль и устранение скрытого среза24 Community с bounded grid. Общая регрессия после delta и внешняя12.5 остаются. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 5467a847..01a7ef0f 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -685,3 +685,12 @@ commRecs строятся через filter(sourceServerId).slice(0,24), а Home WAN1000×JH1:1000→50rows,28019→1419tableDOM,28712→2149pageDOM,2264ms dev-ready. WAN10×JH100:103→8columns,9803→683tableDOM,10595→1492pageDOM,1785ms dev-ready. Полные исходные массивы остаются в памяти. Проверены12browser + повтор2 после lint-правки, TypeScript без incremental, targeted ESLint, production build, ReUI validate_usage/checklist. Мобильные снимки docs/ux-ui/screens/optimizer-wan-matrix-{1000,100jh}-320.png просмотрены. Поиск последних объектов, последний диапазон, cache/refresh/clamp,320px/pinnedWAN и WCAG axe приняты. Общая регрессия после delta ещё требуется.6 manualPagination:true остаются; CPU DFS и срез24 commRecs не исправлены этим блоком. + + +## 12.3.61 — точное отсечение перебора проб + +2026-10-04. DFS сохранил порядок WAN/pool, обязательный выбор совместимой пробы и первое назначение при равных totals. Отсекаются только ветви, чья верхняя граница не превосходит найденного bestSum. Две релаксации: сумма независимых максимумов remainingWAN и сумма наибольших remainingCount максимумов уникальных probe IDs. Каждое допустимое завершение ограничено обеими оценками; минимум также верхняя граница. Никакого изменения правил scores, fallback greedy для >7WAN/недостаточного pool, выбора интерфейсов, API или сетевых операций. + +Независимый старый полный DFS в тесте совпал по exact probe IDs на1200 seeded fixtures с ties/incompatible/partial matching. Дополнительно equal20 сохраняет первые7 и проверен forced early choice.7 pure tests/type/lint,6 main browser и8 context прошли. Скрипт с child timeout10с теперь проверяет probe IDs: equal17.3ms(p0..6), varied16.6ms(p13..19), freshness60.9ms(p13..19). До правки equal превышал10с; первая слабая граница оставляла freshness2269ms, усиленная граница сократила его. Время child startup отдельно335–443ms, в расчёт не входит. Production build проходит. + +Это профили7WAN×20, а не гарантия для любого размера/набора: DFS всё ещё экспоненциальный в худшем случае, исходный pool не ограничен; large mixed-interface/live нагрузка остаётся предметом production измерения. Следующий12.3.62 — скрытый срез24commRecs. diff --git a/lib/route-optimizer-assignment.test.ts b/lib/route-optimizer-assignment.test.ts new file mode 100644 index 00000000..f2cdde58 --- /dev/null +++ b/lib/route-optimizer-assignment.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { buildLiveOptimizerData, DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS, type OptimizerApiServer, type RouteOptimizerSpeedProbe } from "./route-optimizer-data" + +// Independent legacy oracle: forced pick, original pool order, first equal total. +function legacy(wans: string[], pool: RouteOptimizerSpeedProbe[], now: number) { + const score = (p: RouteOptimizerSpeedProbe, iface: string) => { + const want=iface.trim().toLowerCase(), got=p.srcInterface.trim().toLowerCase() + if(got && want && got!==want)return -1 + return (got&&want?100:!got&&want?35:!want&&got?15:5) + +(p.lastPingRttMs!==null?20:0)+(p.lastTxAvgMbps!==null?10:0)+(p.lastRxAvgMbps!==null?10:0) + +(p.lastPingAt?Math.max(0,20-Math.floor((now-Date.parse(p.lastPingAt))/3600000)):0) + } + let best=-Infinity, result: (string|null)[]=[] + function visit(i:number,used:Set,picked:(string|null)[],sum:number){ + if(i===wans.length){if(sum>best){best=sum;result=[...picked]}return} + let matched=false + for(const p of pool){if(used.has(p.id))continue;const value=score(p,wans[i]);if(value<0)continue + matched=true;used.add(p.id);visit(i+1,used,[...picked,p.id],sum+value);used.delete(p.id) + } + if(!matched)visit(i+1,used,[...picked,null],sum) + } + visit(0,new Set(),[],0);return result +} + +const common={host:"192.0.2.1",enabled:true,status:"online" as const,latency:10,country:"RU",site:"TEST",model:"CHR"} +function chosen(wans:string[],probes:RouteOptimizerSpeedProbe[]){ + const rows:OptimizerApiServer[]=[{...common,id:1,name:"Home",type:"home-router",wanUplinks:wans.map((iface,i)=>({id:`w${i}`,name:`WAN${i}`,iface,isp:"ISP",ip:"198.51.100.1",maxDl:100,maxUl:100}))},{...common,id:2,name:"Jump",type:"jump-host"}] + return buildLiveOptimizerData(rows,[],DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,probes).homes[0].wanJhLegs.map(leg=>leg.observed?.probeId??null) +} + +function probe(i:number,iface:string):RouteOptimizerSpeedProbe{return {id:`p${i}`,srcServerId:"1",dstServerId:"2",srcInterface:iface,dstInterface:"",enabled:true,lastPingRttMs:10,lastPingLossPct:0,lastTxAvgMbps:100,lastRxAvgMbps:100,lastPingAt:null}} + +test("branch bound preserves legacy assignments for ties, incompatibility and partial matching",context=>{ + const now=Date.parse("2026-10-04T12:00:00Z");context.mock.method(Date,"now",()=>now) + let seed=917;const random=(n:number)=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return (seed>>>8)%n} + const names=["","ether1","ether2"," ETHER1 "] + for(let fixture=0;fixture<1200;fixture++){ + const count=1+random(4),wans=Array.from({length:count},()=>names[1+random(names.length-1)]) + const pool=Array.from({length:count+random(3)},(_,i)=>({...probe(i,names[random(names.length)]),lastPingRttMs:random(2)?0:null,lastTxAvgMbps:random(2)?100:null,lastRxAvgMbps:random(2)?100:null,lastPingAt:random(3)?null:"2026-10-04T10:00:00Z"})) + assert.deepEqual(chosen(wans,pool),legacy(wans,pool,now),`fixture ${fixture}`) + } +}) + +test("twenty equal wildcard probes keep the first seven in pool order",()=>{ + const wans=Array.from({length:7},(_,i)=>`ether${i}`),pool=Array.from({length:20},(_,i)=>probe(i,"")) + assert.deepEqual(chosen(wans,pool),Array.from({length:7},(_,i)=>`p${i}`)) +}) + +test("early forced choice remains unchanged when a later WAN loses its compatible probe",()=>{ + const wans=["ether1", "ether1"],pool=[probe(0,"ether1"),probe(1,"ether2")] + assert.deepEqual(chosen(wans,pool),["p0",null]) +}) diff --git a/lib/route-optimizer-data.ts b/lib/route-optimizer-data.ts index b7674ef3..57c46df1 100644 --- a/lib/route-optimizer-data.ts +++ b/lib/route-optimizer-data.ts @@ -345,6 +345,30 @@ export function buildLiveOptimizerData( } return } + if (bestAssign) { + // Ignore competition for probes: independent maxima are an upper bound + // on every possible completion, including a WAN with no compatible probe. + let upper = sum + const candidateMax = new Map() + for (let next = i; next < wanList.length; next++) { + let maximum = 0 + for (const candidate of pool) { + if (used.has(candidate.id)) continue + const value = scoreProbeForWanIface(candidate, wanList[next]!.iface) + maximum = Math.max(maximum, value) + candidateMax.set(candidate.id, Math.max(candidateMax.get(candidate.id) ?? 0, value)) + } + upper += maximum + } + // A completion uses at most one distinct probe per remaining WAN. + // Its weight is bounded by the highest remaining probe maxima, even + // when those maxima cannot actually be assigned to different WANs. + const distinctUpper = sum + [...candidateMax.values()].sort((a, b) => b - a) + .slice(0, wanList.length - i).reduce((total, value) => total + value, 0) + upper = Math.min(upper, distinctUpper) + // Equal totals keep the first assignment in the original DFS order. + if (upper <= bestSum) return + } const wan = wanList[i]! let picked = false for (const p of pool) { diff --git a/scripts/performance/optimizer-probes-profile.mjs b/scripts/performance/optimizer-probes-profile.mjs index 6d1e5ada..dfe66e78 100644 --- a/scripts/performance/optimizer-probes-profile.mjs +++ b/scripts/performance/optimizer-probes-profile.mjs @@ -1,5 +1,8 @@ // Pure calculation in a child process: no API, database, browser or network operations. +import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' +const variant=process.argv[2] ?? 'equal'; +if(!['equal','varied','freshness'].includes(variant)) throw new Error('Use equal, varied or freshness'); const code = ` const fs=require('node:fs'),ts=require('typescript'); const source=ts.transpileModule(fs.readFileSync('lib/route-optimizer-data.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; @@ -8,12 +11,16 @@ const code = ` const common={host:'192.0.2.1',enabled:true,status:'online',latency:10,country:'RU',site:'TEST',model:'CHR'}; const rows=[{...common,id:1,name:'Home',type:'home-router',wanUplinks:Array.from({length:7},(_,i)=>({id:'wan'+i,name:'WAN'+i,isp:'ISP',iface:'ether'+i,ip:'198.51.100.1',maxDl:100,maxUl:100}))},{...common,id:2,name:'Jump',type:'jump-host'}]; const probes=Array.from({length:20},(_,i)=>({id:'p'+i,srcServerId:'1',dstServerId:'2',srcInterface:'',dstInterface:'',enabled:true,lastPingRttMs:10,lastPingLossPct:0,lastTxAvgMbps:100,lastRxAvgMbps:100,lastPingAt:null})); + if(${JSON.stringify(variant)}==='varied') probes.forEach((p,i)=>{if(i<13)p.lastRxAvgMbps=null;if(i<6)p.lastTxAvgMbps=null}); + if(${JSON.stringify(variant)}==='freshness') probes.forEach((p,i)=>{p.lastPingAt=new Date(Date.now()-(20-i)*3600000).toISOString()}); const start=performance.now();const result=buildLiveOptimizerData(rows,[],DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,probes); - console.log(JSON.stringify({elapsedMs:performance.now()-start,legs:result.homes[0].wanJhLegs.length})); + console.log(JSON.stringify({elapsedMs:performance.now()-start,legs:result.homes[0].wanJhLegs.length,probeIds:result.homes[0].wanJhLegs.map(leg=>leg.observed.probeId)})); `; const start=performance.now(); const result=spawnSync(process.execPath,['-e',code],{cwd:process.cwd(),timeout:10000,encoding:'utf8',windowsHide:true}); -const profile={wanCount:7,probeCount:20,timeoutMs:10000,elapsedMs:Math.round(performance.now()-start),status:result.status,timedOut:result.error?.code==='ETIMEDOUT',result:result.stdout.trim(),error:result.stderr.trim()}; +const profile={variant,wanCount:7,probeCount:20,timeoutMs:10000,elapsedMs:Math.round(performance.now()-start),status:result.status,timedOut:result.error?.code==='ETIMEDOUT',result:result.stdout.trim(),error:result.stderr.trim()}; console.log(JSON.stringify(profile,null,2)); if(result.error && !profile.timedOut) throw result.error; if(result.status !== 0 && !profile.timedOut) process.exitCode=1; + +if(result.status===0){const measured=JSON.parse(result.stdout);assert.deepEqual(measured.probeIds,Array.from({length:7},(_,i)=>'p'+(variant==='equal'?i:i+13)))}