Skip to content

Commit 5ab799b

Browse files
[feat](advisor): the "no offsetting" check — does the pick survive without compensation?
Step 2 of the formula work, and a correction to my own plan: I had proposed this as a separate button. That was wrong twice over — a control the user must first discover is the exact discoverability failure fixed elsewhere in this app, and a second warning panel would compete with the compensation disclosure rather than complete it. It is now one line attached to that disclosure, which turns "there is a weakness here" into the question that actually matters: does the DECISION depend on it? "Microservices scores 2/5 on Cost efficiency, which you weighted 13.2% … Score weakness more heavily and Serverless (FaaS) wins instead — this pick leans on offsetting." Computed only when a compensation exists; on its own the verdict has nothing to qualify. Additive stays the recommendation — this is a second opinion, and a test asserts running it cannot change what rank() returns. I OVERCLAIMED, AND THE TEST CAUGHT ME. The first doc comment said a geometric mean means "no amount of strength elsewhere fully recovers" a weakness. That is false, and my own test failed proving it: with twelve equally-weighted attributes, one fit of 1 among eleven 5s scores 4.37 — comfortably above a flat row of 3s. Geometric aggregation REDUCES compensability; it does not remove it. Strength still buys off weakness, it simply pays a worse rate. Genuine non-compensation needs a veto or threshold rule (ELECTRE-style), which would need its own ADR. The comment now says this plainly and two tests pin it: one proves the aggregators disagree inside the band where they genuinely do (~32–50% weight on the weak attribute), the other asserts the equal-weight case where the weakness IS bought off, so the overclaim cannot come back. That correction also applies to PR #70's description, which called geometric a proper fix for compensability. It is a partial one. GUARD FIX, NOT A GUARD DODGE. check-language-purity flagged the new Indonesian string for containing the English word "other" — from the `{other}` placeholder. Placeholders are substituted at runtime: they are code, not prose, and reading them as language is simply wrong. The guard now strips `{…}` tokens before analysis rather than the string being reworded to slip past it. Re-verified it still bites. BUNDLE 286→287 total (measured 286.2). Being explicit about which promise applies: the note on the INITIAL budget says a third raise there should trigger splitting the below-the-fold Advisor results into their own chunk. That is a first-paint concern and this is not it — initial sits at 122.5/123 and barely moved. This is the NFR ceiling guard (cap 300), measuring the whole app rather than what loads first. Gates: build clean, lint 0, 265/265 unit, 15/15 E2E, 7/7 guards, 0 axe in both languages and both themes at 390px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f3b11d commit 5ab799b

6 files changed

Lines changed: 117 additions & 4 deletions

File tree

scripts/check-bundle-size.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { gzipSync } from 'node:zlib';
1717
// shared async chunk can't silently be mis-counted. Headroom catches a real regression; raise the
1818
// budgets deliberately (with a note) if the app grows.
1919
const JS_INITIAL_BUDGET_KB = 123; // History, so the pattern stays visible: 120→121 Phase 3 (lazy chunks, stub only). 121→122 briefly on 2026-07-25 for Insights prose, RETURNED to 121 the same day by moving that prose to the lazy chunk — avoidable, and the fix was structural. 121→122 on 2026-07-27 for decision leverage. 122→123 on 2026-07-27 for the compensation disclosure (measured 122.2). THIS IS THE SECOND RAISE IN TWO CHANGES and both are eager Advisor code, so the trend deserves watching, not waving through. Trimmed first, not after: the disclosure prose was cut ~55%, and a newly-imported Tabler icon was dropped for IconScale which the file already had (that one alone brought TOTAL back under budget). The residual ~0.2kB is the feature itself on the default tab. If a third raise comes up, the answer is probably to split the Advisor results below the fold into their own chunk rather than to move this number again.
20-
const JS_TOTAL_BUDGET_KB = 286; // raised 200→260 (Insights bilingualisation 2026-07-15); 260→268 for the Phase 3 Chat Advisor 2026-07-19 (adapter + hook + panel, all in a LAZY chunk — the FAB is lazy too, so the initial budget is untouched); 268→278 for the 2026-07-23 scenario-coverage expansion (cost/ops, risk catalog, sensitivity, migration, dimension/factor/QA lookups, app-usage FAQ — all pure data-driven text in the same lazy chunk; NFR cap is 300); 278→281 for the 2026-07-25 Chat Advisor capability browser + "How to use this" pane (bilingual help copy, same lazy chunk — initial JS unchanged at 120.8/121, so first paint is unaffected); 281→284 for the 2026-07-25 role-based "Start here" section in the Guide (six bilingual role paths — prose only, in the already-lazy ManualBook chunk that loads on demand; initial JS still 120.8/121, NFR cap 300) ; 284→286 on 2026-07-25 for the seven deepened Insights section intros — real added prose, but it now lives in the LAZY Insights chunk rather than the eager dict, which is why the initial budget went DOWN in the same change. NFR cap is 300.
20+
const JS_TOTAL_BUDGET_KB = 287; // raised 200→260 (Insights bilingualisation 2026-07-15); 260→268 for the Phase 3 Chat Advisor 2026-07-19 (adapter + hook + panel, all in a LAZY chunk — the FAB is lazy too, so the initial budget is untouched); 268→278 for the 2026-07-23 scenario-coverage expansion (cost/ops, risk catalog, sensitivity, migration, dimension/factor/QA lookups, app-usage FAQ — all pure data-driven text in the same lazy chunk; NFR cap is 300); 278→281 for the 2026-07-25 Chat Advisor capability browser + "How to use this" pane (bilingual help copy, same lazy chunk — initial JS unchanged at 120.8/121, so first paint is unaffected); 281→284 for the 2026-07-25 role-based "Start here" section in the Guide (six bilingual role paths — prose only, in the already-lazy ManualBook chunk that loads on demand; initial JS still 120.8/121, NFR cap 300) ; 284→286 on 2026-07-25 for the seven deepened Insights section intros — real added prose, but it now lives in the LAZY Insights chunk rather than the eager dict, which is why the initial budget went DOWN in the same change. NFR cap is 300. ; 286→287 on 2026-07-27 for the non-compensatory check (measured 286.2). To be explicit about which promise applies: the note on JS_INITIAL_BUDGET_KB says a third raise there should trigger splitting the below-the-fold Advisor results into their own chunk. That is a FIRST-PAINT concern and it is not this — initial sits at 122.5/123 and did not move enough to matter. This budget is the NFR ceiling guard (cap 300), measuring the whole app rather than what loads first, so a 0.2kB step for a genuinely new engine function plus two short strings is what it is meant to absorb.
2121
const CSS_BUDGET_KB = 29; // 25→27 (Fase 2g polish 2026-07-18); 27→29 for Phase 3 2026-07-19 (chat panel + copilot overlay/launcher/Dos-Don'ts cards); still under the ~30kB NFR ceiling
2222

2323
const dir = 'dist/assets';

scripts/check-language-purity.mjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,20 @@ const Q = "(?:'((?:[^'\\\\]|\\\\.)*)'|\"((?:[^\"\\\\]|\\\\.)*)\")";
2828
const RX_OBJ = new RegExp(`en:\\s*${Q}\\s*,\\s*id:\\s*${Q}`, 'g');
2929
const RX_FN = new RegExp(`\\b[Lpb]\\(\\s*${Q}\\s*,\\s*${Q}\\s*[,)]`, 'gs');
3030

31-
/** Strip hyphenated compounds first — they are single technical terms, not prose. */
32-
const words = (s) => (s.toLowerCase().replace(/[a-z]+-[a-z-]+/g, ' ').match(/[a-z]+/g) || []);
31+
/**
32+
* Reduce a string to the words that are genuinely prose.
33+
*
34+
* `{placeholder}` tokens go first: they are substituted at runtime and are code, not language, so a
35+
* key like `{other} yang unggul` must not be read as an Indonesian sentence containing the English
36+
* word "other". Hyphenated compounds go next, because "append-only" and "read-only" are single
37+
* technical terms whose tail would otherwise look like the English word "only".
38+
*/
39+
const words = (s) =>
40+
(s
41+
.toLowerCase()
42+
.replace(/\{[^}]*\}/g, ' ')
43+
.replace(/[a-z]+-[a-z-]+/g, ' ')
44+
.match(/[a-z]+/g) || []);
3345

3446
const files = execSync('grep -rl "en:\\|L(\'\\|p(\'" src/ content/ --include=*.ts --include=*.tsx --include=*.json 2>/dev/null || true', { encoding: 'utf8' })
3547
.trim().split('\n').filter(Boolean).filter((f) => !f.includes('.test.'));

src/components/advisor/DimensionDetail.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { useI18n } from '../../i18n/I18nContext';
1717
import { DIMENSIONS } from '../../config/dimensions';
1818
import { DIM_NARRATIVE, OPTION_BLURB } from '../../config/dimensionContent';
1919
import { QUALITY_ATTRIBUTES } from '../../config/qualityAttributes';
20-
import { compensations, contributions, displayScore } from '../../lib/scoring';
20+
import { compensations, contributions, displayScore, nonCompensatoryWinner } from '../../lib/scoring';
2121
import { WhyNotRunnerUp } from './WhyNotRunnerUp';
2222
import type { DimensionId, RankedOption, Weights } from '../../types';
2323

@@ -56,6 +56,10 @@ export function DimensionDetail({ dim, ranked, weights }: Props) {
5656
// on qualifies, and it fires on 28.2% of sampled decisions), but a wall of warnings reads as
5757
// noise and gets skipped — which would defeat the disclosure. The heaviest two carry the point.
5858
const comps = compensations(weights, topOption.qaFit).slice(0, 2);
59+
// Only computed when a compensation exists — on its own the verdict has nothing to qualify, and
60+
// a standalone panel would be a second warning competing with the first. Attached here it turns
61+
// "there is a weakness" into the question that actually matters: does the DECISION depend on it?
62+
const strictWinner = comps.length > 0 ? nonCompensatoryWinner(weights, dim) : null;
5963
const maxPts = Math.max(...contrib.map((c) => c.points), 0.001);
6064

6165
return (
@@ -137,6 +141,13 @@ export function DimensionDetail({ dim, ranked, weights }: Props) {
137141
.replace('{weight}', c.weight.toFixed(1))}
138142
</p>
139143
))}
144+
{strictWinner && (
145+
<p style={{ fontSize: '12px', lineHeight: 1.55, color: 'var(--color-text-secondary)', margin: '5px 0 0', fontWeight: 500 }}>
146+
{strictWinner === top.name
147+
? t('comp.holds')
148+
: t('comp.flips').replace('{other}', strictWinner)}
149+
</p>
150+
)}
140151
</div>
141152
</div>
142153
)}

src/i18n/dict.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export const DICT = {
8686
en: '{option} scores {fit}/5 on {qa}, which you weighted {weight}% — its strengths elsewhere outweigh that. Check the trade is one you can accept.',
8787
id: '{option} bernilai {fit}/5 pada {qa}, padahal Anda memberinya bobot {weight}% — kekuatannya di tempat lain menutupi hal itu. Pastikan pertukaran itu bisa Anda terima.',
8888
},
89+
'comp.holds': { en: 'Still wins even when weakness counts double.', id: 'Tetap unggul meski kelemahan dihitung lebih berat.' },
90+
'comp.flips': {
91+
en: 'Score weakness more heavily and {other} wins instead — this pick leans on offsetting.',
92+
id: 'Bila kelemahan dinilai lebih berat, {other} yang unggul — pilihan ini bertumpu pada penutupan kelemahan.',
93+
},
8994
'detail.contrib': {
9095
en: 'Top contributing attributes (weight × fit)',
9196
id: 'Atribut penyumbang teratas (bobot × kecocokan)',

src/lib/scoring.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
contributions,
1111
leverage,
1212
compensations,
13+
nonCompensatoryWinner,
1314
} from './scoring';
1415
import { DIMENSION_ORDER, DIMENSIONS } from '../config/dimensions';
1516
import { FACTOR_ORDER } from '../config/factors';
@@ -271,3 +272,52 @@ describe('compensations — where the winner won despite being weak', () => {
271272
expect(compensations(w, fit)).toEqual(compensations(w, fit));
272273
});
273274
});
275+
276+
describe('nonCompensatoryWinner — the "no offsetting" check', () => {
277+
const flat = () => Object.fromEntries(QA_ORDER.map((q) => [q, 100 / QA_ORDER.length])) as never;
278+
279+
it('returns a real option of the dimension', () => {
280+
for (const dim of DIMENSION_ORDER) {
281+
const names = DIMENSIONS[dim].options.map((o) => o.name);
282+
expect(names).toContain(nonCompensatoryWinner(flat(), dim));
283+
}
284+
});
285+
286+
it('is deterministic', () => {
287+
for (const dim of DIMENSION_ORDER) {
288+
expect(nonCompensatoryWinner(flat(), dim)).toBe(nonCompensatoryWinner(flat(), dim));
289+
}
290+
});
291+
292+
/**
293+
* The whole point: geometric aggregation must actually punish a weakness that additive forgives.
294+
* A synthetic pair proves the mechanism rather than trusting the real matrices to exercise it —
295+
* "all 3s" beats "one 1 and the rest 5s" under geometric, while additive prefers the latter.
296+
*/
297+
it('makes strength pay a worse rate for weakness than the additive sum does', () => {
298+
// 40% of the weight on the weak attribute — inside the band where the two aggregators genuinely
299+
// disagree. Below ~32% even geometric still prefers the spike, which is exactly the overclaim
300+
// this test exists to prevent: geometric reduces compensability, it does not remove it.
301+
const w = Object.fromEntries(QA_ORDER.map((q, i) => [q, i === 0 ? 40 : 60 / (QA_ORDER.length - 1)])) as never;
302+
const even = QA_ORDER.map(() => 3);
303+
const spiky = QA_ORDER.map((_, i) => (i === 0 ? 1 : 5));
304+
const geo = (fit: number[]) => Math.exp(QA_ORDER.reduce((s, q, i) => s + ((w as Record<string, number>)[q] / 100) * Math.log(fit[i]), 0));
305+
expect(composite(w, spiky)).toBeGreaterThan(composite(w, even)); // additive still prefers the spike
306+
expect(geo(even)).toBeGreaterThan(geo(spiky)); // geometric no longer does
307+
});
308+
309+
/** Guards the claim in the doc comment: at equal weights the weakness IS still bought off. */
310+
it('does not pretend to be fully non-compensatory', () => {
311+
const w = flat();
312+
const geo = (fit: number[]) => Math.exp(QA_ORDER.reduce((s, q, i) => s + ((w as Record<string, number>)[q] / 100) * Math.log(fit[i]), 0));
313+
expect(geo(QA_ORDER.map((_, i) => (i === 0 ? 1 : 5)))).toBeGreaterThan(geo(QA_ORDER.map(() => 3)));
314+
});
315+
316+
/** It is a check, never the recommendation: it must not be able to change what rank() returns. */
317+
it('does not affect the ranking the app recommends', () => {
318+
const levels = { team: 2, scale: 2, budget: 1, consistency: 2 };
319+
const before = rank(levels, 'D1').map((r) => r.name);
320+
nonCompensatoryWinner(deriveWeights(levels), 'D1');
321+
expect(rank(levels, 'D1').map((r) => r.name)).toEqual(before);
322+
});
323+
});

src/lib/scoring.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,38 @@ export function compensations(weights: Weights, qaFit: number[]): Compensation[]
261261
.filter((c) => c.weight > floor && c.fit <= COMP_WEAK_FIT)
262262
.sort((a, b) => b.weight - a.weight || QA_ORDER.indexOf(a.qa) - QA_ORDER.indexOf(b.qa));
263263
}
264+
265+
/**
266+
* Step 7 — THE NON-COMPENSATORY CHECK: does this pick survive if weakness cannot be offset?
267+
*
268+
* `composite()` adds weighted fits, so a 1 on one attribute is repaid one-for-one by a 5 on another
269+
* at the same weight. A weighted GEOMETRIC mean — exp(Σ wⱼ·ln φⱼ) — makes that repayment far more
270+
* expensive: the marginal value of extra strength falls as the weakness deepens.
271+
*
272+
* Precisely, and this is worth stating because it is easy to overclaim: geometric aggregation
273+
* REDUCES compensability, it does not remove it. With twelve attributes at equal weight, one fit of
274+
* 1 among eleven 5s still scores 4.37 — comfortably above a flat row of 3s. Strength does still buy
275+
* off weakness; it simply costs more. Only a veto or threshold rule (ELECTRE-style) is genuinely
276+
* non-compensatory, and that would need its own justification and its own ADR.
277+
*
278+
* Over the same 4,000-scenario sample the two aggregators disagree about the winner in 12.4% of
279+
* decisions, which is what makes it useful as a second opinion.
280+
*
281+
* This is used as a CHECK, never as the recommendation. Re-ranking the app on geometric aggregation
282+
* would silently move the advice in one decision in eight and require re-freezing the model; running
283+
* it alongside costs nothing and answers a question the additive score cannot: is the winner ahead
284+
* on merit across the board, or ahead because its weaknesses were paid for?
285+
*
286+
* Returns the geometric winner's name. Ties break on canonical config order, exactly as `rankWith`
287+
* does, so a tie can never make the two look like they disagree when they do not.
288+
*/
289+
export function nonCompensatoryWinner(weights: Weights, dim: DimensionId): string {
290+
return DIMENSIONS[dim].options
291+
.map((opt, index) => ({
292+
name: opt.name,
293+
index,
294+
// Fits are 1–5, so ln is always defined and finite; no clamping needed.
295+
score: Math.exp(QA_ORDER.reduce((s, q, i) => s + (weights[q] / 100) * Math.log(opt.qaFit[i] ?? 3), 0)),
296+
}))
297+
.sort((a, b) => b.score - a.score || a.index - b.index)[0].name;
298+
}

0 commit comments

Comments
 (0)