Honor Scattermind lane hints in rank feedback
This commit is contained in:
@@ -51,6 +51,7 @@ try {
|
|||||||
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.', dependencies: ['auth', 'teams', 'saved projects', 'sync'], risk: 'Turns the bridge into generic dashboard swamp before the first proof.' },
|
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.', dependencies: ['auth', 'teams', 'saved projects', 'sync'], risk: 'Turns the bridge into generic dashboard swamp before the first proof.' },
|
||||||
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.', dependencies: ['account model', 'checkout', 'fulfillment'], risk: 'Payment machinery before the continuation value is proven.' },
|
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.', dependencies: ['account model', 'checkout', 'fulfillment'], risk: 'Payment machinery before the continuation value is proven.' },
|
||||||
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.', evidenceNeeded: 'Does a plain brief help a user act within 48 hours?', recommendedLane: 'validate-next' },
|
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.', evidenceNeeded: 'Does a plain brief help a user act within 48 hours?', recommendedLane: 'validate-next' },
|
||||||
|
{ id: 'parked-bridge-dashboard', title: 'Saved Snapshot dashboard with provenance', description: 'Looks bridge-adjacent, but Scattermind already marked it as not-now because it needs auth, saved workspaces, and collaboration first.', recommendedLane: 'park', sourceSection: 'concept-map.parkingLot' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -61,14 +62,19 @@ try {
|
|||||||
assert.equal(data.input.provenance.artifactId, 'snapshot_123');
|
assert.equal(data.input.provenance.artifactId, 'snapshot_123');
|
||||||
assert.equal(data.input.provenance.source, 'Scattermind');
|
assert.equal(data.input.provenance.source, 'Scattermind');
|
||||||
assert.match(data.input.provenance.originalPrompt, /tiny shop idea/);
|
assert.match(data.input.provenance.originalPrompt, /tiny shop idea/);
|
||||||
assert.equal(data.ranked.length, 5);
|
assert.equal(data.ranked.length, 6);
|
||||||
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
|
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
|
||||||
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
|
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
|
||||||
assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture');
|
assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture');
|
||||||
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'workspace').lane.id));
|
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'workspace').lane.id));
|
||||||
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id));
|
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id));
|
||||||
|
assert.equal(data.ranked.find(item => item.id === 'parked-bridge-dashboard').lane.id, 'park', 'explicit Scattermind park hints must stay out of the active proof slice');
|
||||||
|
assert.equal(data.ranked.find(item => item.id === 'parked-bridge-dashboard').lane.source, 'hint');
|
||||||
assert.equal(data.ranked.find(item => item.id === 'bridge-contract').provenance.sourceSection, 'concept-map.nextMoves');
|
assert.equal(data.ranked.find(item => item.id === 'bridge-contract').provenance.sourceSection, 'concept-map.nextMoves');
|
||||||
assert.match(data.ranked.find(item => item.id === 'bridge-contract').factors.evidenceNeeded, /Concept Map/);
|
assert.match(data.ranked.find(item => item.id === 'bridge-contract').factors.evidenceNeeded, /Concept Map/);
|
||||||
|
assert.equal(data.brief.source.artifactId, 'snapshot_123');
|
||||||
|
assert.match(data.brief.summary, /Source: Tiny shop idea clarity pass · snapshot_123/);
|
||||||
|
assert.ok(data.brief.next48Hours.some(item => /Open the source artifact \(snapshot_123\)/i.test(item)));
|
||||||
assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
|
assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
|
||||||
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
|
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
|
||||||
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||||
|
|||||||
@@ -428,7 +428,9 @@ function scoreOption(option, mode, context = '') {
|
|||||||
const swampHits = hits(text, ['dashboard', 'workspace', 'workspaces', 'auth', 'accounts', 'billing', 'subscription', 'team voting', 'collaboration', 'admin']);
|
const swampHits = hits(text, ['dashboard', 'workspace', 'workspaces', 'auth', 'accounts', 'billing', 'subscription', 'team voting', 'collaboration', 'admin']);
|
||||||
const dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45);
|
const dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45);
|
||||||
const laneHint = factors.recommendedLane || '';
|
const laneHint = factors.recommendedLane || '';
|
||||||
|
const normalizedLaneHint = normalizeLaneHint(laneHint);
|
||||||
const laneBoost = /do|first|now|build/.test(laneHint) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
|
const laneBoost = /do|first|now|build/.test(laneHint) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
|
||||||
|
const lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15),
|
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15),
|
||||||
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))),
|
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))),
|
||||||
@@ -441,12 +443,29 @@ function scoreOption(option, mode, context = '') {
|
|||||||
const weights = mode.weights;
|
const weights = mode.weights;
|
||||||
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
|
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
|
||||||
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
|
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
|
||||||
const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100)));
|
const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100) - lanePenalty));
|
||||||
return { ...metrics, score };
|
return { ...metrics, score };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLaneHint(value = '') {
|
||||||
|
const hint = cleanText(value, 40).toLowerCase().replace(/_/g, '-');
|
||||||
|
if (/^(do|do-first|first|build|build-now|now)$/.test(hint)) return 'do';
|
||||||
|
if (/^(validate|validate-next|test|test-next|proof|evidence)$/.test(hint)) return 'test';
|
||||||
|
if (/^(defer|later|sequence-later|after-proof)$/.test(hint)) return 'defer';
|
||||||
|
if (/^(park|cut|drop|icebox|not-now)$/.test(hint)) return 'park';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function laneFor(option, rankIndex, total) {
|
function laneFor(option, rankIndex, total) {
|
||||||
|
const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || '');
|
||||||
|
// Ranker should defend build order, not blindly obey Scattermind. Positive
|
||||||
|
// hints can nudge scoring, but explicit negative hints are safety rails:
|
||||||
|
// if the source already marked something as defer/park, never promote it
|
||||||
|
// into the active proof slice just because keyword scoring liked it.
|
||||||
|
if (hintedLane === 'park') return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan', source: 'hint' };
|
||||||
|
if (hintedLane === 'defer') return { id: 'defer', label: 'Defer', action: 'Sequence after proof', source: 'hint' };
|
||||||
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
||||||
|
if (hintedLane === 'test') return { id: 'test', label: 'Validate next', action: 'Find evidence', source: 'hint' };
|
||||||
if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
||||||
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
|
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
|
||||||
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||||||
@@ -473,15 +492,23 @@ function concernFor(option) {
|
|||||||
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDecisionBrief({ idea, context, mode, ranked }) {
|
function createDecisionBrief({ idea, context, mode, ranked, provenance }) {
|
||||||
const top = ranked[0];
|
const top = ranked[0];
|
||||||
const second = ranked[1];
|
const second = ranked[1];
|
||||||
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
||||||
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||||||
|
const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · ');
|
||||||
const theme = top ? `The strongest signal is “${top.title}” because it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
const theme = top ? `The strongest signal is “${top.title}” because it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||||||
return {
|
return {
|
||||||
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
||||||
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}`,
|
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`,
|
||||||
|
source: provenance ? {
|
||||||
|
schema: provenance.schema,
|
||||||
|
source: provenance.source,
|
||||||
|
artifactId: provenance.artifactId,
|
||||||
|
snapshotTitle: provenance.snapshotTitle,
|
||||||
|
conceptMapId: provenance.conceptMapId,
|
||||||
|
} : null,
|
||||||
expertReflections: [
|
expertReflections: [
|
||||||
{
|
{
|
||||||
lens: 'Product expert',
|
lens: 'Product expert',
|
||||||
@@ -497,7 +524,7 @@ function createDecisionBrief({ idea, context, mode, ranked }) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
next48Hours: top ? [
|
next48Hours: top ? [
|
||||||
`Write a one-paragraph test for “${top.title}”.`,
|
provenance?.artifactId ? `Open the source artifact (${provenance.artifactId}) and mark “${top.title}” as the defended first move.` : `Write a one-paragraph test for “${top.title}”.`,
|
||||||
top.factors?.evidenceNeeded ? `Evidence to collect: ${top.factors.evidenceNeeded}.` : 'Name the evidence that would make this decision obviously right or wrong.',
|
top.factors?.evidenceNeeded ? `Evidence to collect: ${top.factors.evidenceNeeded}.` : 'Name the evidence that would make this decision obviously right or wrong.',
|
||||||
'Put it in front of 3–5 real people or run it manually once.',
|
'Put it in front of 3–5 real people or run it manually once.',
|
||||||
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
||||||
@@ -531,7 +558,7 @@ app.post('/api/rank-feedback', (req, res) => {
|
|||||||
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
||||||
input: { idea, context, optionCount: options.length, provenance },
|
input: { idea, context, optionCount: options.length, provenance },
|
||||||
ranked: options,
|
ranked: options,
|
||||||
brief: createDecisionBrief({ idea, context, mode, ranked: options }),
|
brief: createDecisionBrief({ idea, context, mode, ranked: options, provenance }),
|
||||||
buildOrder: {
|
buildOrder: {
|
||||||
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
|
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
|
||||||
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),
|
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),
|
||||||
|
|||||||
Reference in New Issue
Block a user