feat(member-work-sync): assess phase two readiness
This commit is contained in:
parent
440548531b
commit
c5a97fd796
8 changed files with 281 additions and 2 deletions
|
|
@ -33,6 +33,7 @@ Current implementation note:
|
||||||
|
|
||||||
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface.
|
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface.
|
||||||
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
|
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
|
||||||
|
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
||||||
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
||||||
|
|
||||||
Patterns used:
|
Patterns used:
|
||||||
|
|
@ -2981,6 +2982,7 @@ Check:
|
||||||
- stale report rate;
|
- stale report rate;
|
||||||
- invalid caught-up attempts;
|
- invalid caught-up attempts;
|
||||||
- how many nudges Phase 2 would send.
|
- how many nudges Phase 2 would send.
|
||||||
|
- `phase2Readiness.state` remains `collecting_shadow_data` until the sample is large enough, `blocked` if rates are noisy, and only then `shadow_ready`.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,45 @@ export interface MemberWorkSyncTeamMetrics {
|
||||||
reportAcceptedCount: number;
|
reportAcceptedCount: number;
|
||||||
reportRejectedCount: number;
|
reportRejectedCount: number;
|
||||||
recentEvents: MemberWorkSyncMetricEvent[];
|
recentEvents: MemberWorkSyncMetricEvent[];
|
||||||
|
phase2Readiness: MemberWorkSyncPhase2ReadinessAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemberWorkSyncPhase2ReadinessState =
|
||||||
|
| 'collecting_shadow_data'
|
||||||
|
| 'shadow_ready'
|
||||||
|
| 'blocked';
|
||||||
|
|
||||||
|
export type MemberWorkSyncPhase2ReadinessReason =
|
||||||
|
| 'insufficient_members'
|
||||||
|
| 'insufficient_status_events'
|
||||||
|
| 'insufficient_observation_window'
|
||||||
|
| 'would_nudge_rate_high'
|
||||||
|
| 'fingerprint_churn_high'
|
||||||
|
| 'report_rejection_rate_high';
|
||||||
|
|
||||||
|
export interface MemberWorkSyncPhase2ReadinessThresholds {
|
||||||
|
minObservedMembers: number;
|
||||||
|
minStatusEvents: number;
|
||||||
|
minObservationHours: number;
|
||||||
|
maxWouldNudgesPerMemberHour: number;
|
||||||
|
maxFingerprintChangesPerMemberHour: number;
|
||||||
|
maxReportRejectionRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncPhase2ReadinessRates {
|
||||||
|
observationHours: number;
|
||||||
|
statusEventCount: number;
|
||||||
|
wouldNudgesPerMemberHour: number;
|
||||||
|
fingerprintChangesPerMemberHour: number;
|
||||||
|
reportRejectionRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkSyncPhase2ReadinessAssessment {
|
||||||
|
state: MemberWorkSyncPhase2ReadinessState;
|
||||||
|
reasons: MemberWorkSyncPhase2ReadinessReason[];
|
||||||
|
thresholds: MemberWorkSyncPhase2ReadinessThresholds;
|
||||||
|
rates: MemberWorkSyncPhase2ReadinessRates;
|
||||||
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncReportRequest {
|
export interface MemberWorkSyncReportRequest {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts';
|
import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts';
|
||||||
|
import { assessMemberWorkSyncPhase2Readiness } from '../domain';
|
||||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
||||||
function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics {
|
function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics {
|
||||||
|
|
@ -20,6 +21,10 @@ function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeam
|
||||||
reportAcceptedCount: 0,
|
reportAcceptedCount: 0,
|
||||||
reportRejectedCount: 0,
|
reportRejectedCount: 0,
|
||||||
recentEvents: [],
|
recentEvents: [],
|
||||||
|
phase2Readiness: assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount: 0,
|
||||||
|
recentEvents: [],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +35,15 @@ export class MemberWorkSyncMetricsReader {
|
||||||
if (!this.deps.statusStore.readTeamMetrics) {
|
if (!this.deps.statusStore.readTeamMetrics) {
|
||||||
return emptyMetrics(request.teamName, this.deps.clock.now().toISOString());
|
return emptyMetrics(request.teamName, this.deps.clock.now().toISOString());
|
||||||
}
|
}
|
||||||
return this.deps.statusStore.readTeamMetrics(request.teamName);
|
const metrics = await this.deps.statusStore.readTeamMetrics(request.teamName);
|
||||||
|
return {
|
||||||
|
...metrics,
|
||||||
|
phase2Readiness:
|
||||||
|
metrics.phase2Readiness ??
|
||||||
|
assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount: metrics.memberCount,
|
||||||
|
recentEvents: metrics.recentEvents,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type {
|
||||||
|
MemberWorkSyncMetricEvent,
|
||||||
|
MemberWorkSyncPhase2ReadinessAssessment,
|
||||||
|
MemberWorkSyncPhase2ReadinessReason,
|
||||||
|
MemberWorkSyncPhase2ReadinessThresholds,
|
||||||
|
} from '../../contracts';
|
||||||
|
|
||||||
|
export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyncPhase2ReadinessThresholds =
|
||||||
|
{
|
||||||
|
minObservedMembers: 1,
|
||||||
|
minStatusEvents: 20,
|
||||||
|
minObservationHours: 1,
|
||||||
|
maxWouldNudgesPerMemberHour: 2,
|
||||||
|
maxFingerprintChangesPerMemberHour: 1,
|
||||||
|
maxReportRejectionRate: 0.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AssessMemberWorkSyncPhase2ReadinessInput {
|
||||||
|
memberCount: number;
|
||||||
|
recentEvents: MemberWorkSyncMetricEvent[];
|
||||||
|
thresholds?: Partial<MemberWorkSyncPhase2ReadinessThresholds>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(value: string): number | null {
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObservationHours(events: MemberWorkSyncMetricEvent[]): number {
|
||||||
|
const times = events.flatMap((event) => {
|
||||||
|
const time = parseTime(event.recordedAt);
|
||||||
|
return time == null ? [] : [time];
|
||||||
|
});
|
||||||
|
if (times.length < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const min = Math.min(...times);
|
||||||
|
const max = Math.max(...times);
|
||||||
|
return Math.max(0, (max - min) / 3_600_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRate(value: number): number {
|
||||||
|
return Math.round(value * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushIf(
|
||||||
|
reasons: MemberWorkSyncPhase2ReadinessReason[],
|
||||||
|
condition: boolean,
|
||||||
|
reason: MemberWorkSyncPhase2ReadinessReason
|
||||||
|
): void {
|
||||||
|
if (condition) {
|
||||||
|
reasons.push(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount,
|
||||||
|
recentEvents,
|
||||||
|
thresholds: thresholdOverrides,
|
||||||
|
}: AssessMemberWorkSyncPhase2ReadinessInput): MemberWorkSyncPhase2ReadinessAssessment {
|
||||||
|
const thresholds = {
|
||||||
|
...DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS,
|
||||||
|
...thresholdOverrides,
|
||||||
|
};
|
||||||
|
const statusEvents = recentEvents.filter((event) => event.kind === 'status_evaluated');
|
||||||
|
const wouldNudgeEvents = recentEvents.filter((event) => event.kind === 'would_nudge');
|
||||||
|
const fingerprintChangeEvents = recentEvents.filter(
|
||||||
|
(event) => event.kind === 'fingerprint_changed'
|
||||||
|
);
|
||||||
|
const reportAcceptedEvents = recentEvents.filter((event) => event.kind === 'report_accepted');
|
||||||
|
const reportRejectedEvents = recentEvents.filter((event) => event.kind === 'report_rejected');
|
||||||
|
const observationHours = getObservationHours(recentEvents);
|
||||||
|
const memberHourDenominator = Math.max(memberCount, 1) * Math.max(observationHours, 1 / 60);
|
||||||
|
const wouldNudgesPerMemberHour = wouldNudgeEvents.length / memberHourDenominator;
|
||||||
|
const fingerprintChangesPerMemberHour = fingerprintChangeEvents.length / memberHourDenominator;
|
||||||
|
const reportEventCount = reportAcceptedEvents.length + reportRejectedEvents.length;
|
||||||
|
const reportRejectionRate =
|
||||||
|
reportEventCount > 0 ? reportRejectedEvents.length / reportEventCount : 0;
|
||||||
|
|
||||||
|
const collectingReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
|
||||||
|
pushIf(collectingReasons, memberCount < thresholds.minObservedMembers, 'insufficient_members');
|
||||||
|
pushIf(
|
||||||
|
collectingReasons,
|
||||||
|
statusEvents.length < thresholds.minStatusEvents,
|
||||||
|
'insufficient_status_events'
|
||||||
|
);
|
||||||
|
pushIf(
|
||||||
|
collectingReasons,
|
||||||
|
observationHours < thresholds.minObservationHours,
|
||||||
|
'insufficient_observation_window'
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockingReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
|
||||||
|
pushIf(
|
||||||
|
blockingReasons,
|
||||||
|
wouldNudgesPerMemberHour > thresholds.maxWouldNudgesPerMemberHour,
|
||||||
|
'would_nudge_rate_high'
|
||||||
|
);
|
||||||
|
pushIf(
|
||||||
|
blockingReasons,
|
||||||
|
fingerprintChangesPerMemberHour > thresholds.maxFingerprintChangesPerMemberHour,
|
||||||
|
'fingerprint_churn_high'
|
||||||
|
);
|
||||||
|
pushIf(
|
||||||
|
blockingReasons,
|
||||||
|
reportRejectionRate > thresholds.maxReportRejectionRate,
|
||||||
|
'report_rejection_rate_high'
|
||||||
|
);
|
||||||
|
|
||||||
|
const state =
|
||||||
|
collectingReasons.length > 0
|
||||||
|
? 'collecting_shadow_data'
|
||||||
|
: blockingReasons.length > 0
|
||||||
|
? 'blocked'
|
||||||
|
: 'shadow_ready';
|
||||||
|
const reasons = [...collectingReasons, ...blockingReasons];
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
reasons,
|
||||||
|
thresholds,
|
||||||
|
rates: {
|
||||||
|
observationHours: roundRate(observationHours),
|
||||||
|
statusEventCount: statusEvents.length,
|
||||||
|
wouldNudgesPerMemberHour: roundRate(wouldNudgesPerMemberHour),
|
||||||
|
fingerprintChangesPerMemberHour: roundRate(fingerprintChangesPerMemberHour),
|
||||||
|
reportRejectionRate: roundRate(reportRejectionRate),
|
||||||
|
},
|
||||||
|
diagnostics: reasons.map((reason) => `phase2_readiness:${reason}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,5 +2,6 @@ export * from './ActionableWorkAgenda';
|
||||||
export * from './AgendaFingerprint';
|
export * from './AgendaFingerprint';
|
||||||
export * from './currentReviewCycle';
|
export * from './currentReviewCycle';
|
||||||
export * from './memberName';
|
export * from './memberName';
|
||||||
|
export * from './MemberWorkSyncPhase2Readiness';
|
||||||
export * from './MemberWorkSyncReportValidator';
|
export * from './MemberWorkSyncReportValidator';
|
||||||
export * from './SyncDecisionPolicy';
|
export * from './SyncDecisionPolicy';
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
MemberWorkSyncStatusState,
|
MemberWorkSyncStatusState,
|
||||||
MemberWorkSyncTeamMetrics,
|
MemberWorkSyncTeamMetrics,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
|
import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain';
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncReportStorePort,
|
MemberWorkSyncReportStorePort,
|
||||||
MemberWorkSyncStatusStorePort,
|
MemberWorkSyncStatusStorePort,
|
||||||
|
|
@ -234,7 +235,7 @@ export class JsonMemberWorkSyncStore
|
||||||
const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) =>
|
const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) =>
|
||||||
left.recordedAt.localeCompare(right.recordedAt)
|
left.recordedAt.localeCompare(right.recordedAt)
|
||||||
);
|
);
|
||||||
return {
|
const metrics = {
|
||||||
teamName,
|
teamName,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
memberCount: members.length,
|
memberCount: members.length,
|
||||||
|
|
@ -250,6 +251,13 @@ export class JsonMemberWorkSyncStore
|
||||||
.length,
|
.length,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
...metrics,
|
||||||
|
phase2Readiness: assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount: metrics.memberCount,
|
||||||
|
recentEvents: metrics.recentEvents,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
|
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { assessMemberWorkSyncPhase2Readiness } from '@features/member-work-sync/core/domain';
|
||||||
|
import type { MemberWorkSyncMetricEvent } from '@features/member-work-sync/contracts';
|
||||||
|
|
||||||
|
function event(
|
||||||
|
index: number,
|
||||||
|
kind: MemberWorkSyncMetricEvent['kind'],
|
||||||
|
recordedAt: string
|
||||||
|
): MemberWorkSyncMetricEvent {
|
||||||
|
return {
|
||||||
|
id: `event-${index}-${kind}`,
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: index % 2 === 0 ? 'bob' : 'alice',
|
||||||
|
kind,
|
||||||
|
state: 'needs_sync',
|
||||||
|
agendaFingerprint: `agenda:v1:${index}`,
|
||||||
|
recordedAt,
|
||||||
|
actionableCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusEvents(count: number, start = Date.parse('2026-04-29T00:00:00.000Z')) {
|
||||||
|
return Array.from({ length: count }, (_, index) =>
|
||||||
|
event(index, 'status_evaluated', new Date(start + index * 6 * 60_000).toISOString())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('assessMemberWorkSyncPhase2Readiness', () => {
|
||||||
|
it('keeps Phase 2 collecting until enough shadow data exists', () => {
|
||||||
|
const assessment = assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount: 0,
|
||||||
|
recentEvents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.state).toBe('collecting_shadow_data');
|
||||||
|
expect(assessment.reasons).toEqual([
|
||||||
|
'insufficient_members',
|
||||||
|
'insufficient_status_events',
|
||||||
|
'insufficient_observation_window',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports shadow-ready only when sample size and rates are acceptable', () => {
|
||||||
|
const assessment = assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount: 2,
|
||||||
|
recentEvents: statusEvents(24),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.state).toBe('shadow_ready');
|
||||||
|
expect(assessment.reasons).toEqual([]);
|
||||||
|
expect(assessment.rates.statusEventCount).toBe(24);
|
||||||
|
expect(assessment.rates.observationHours).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks Phase 2 when would-nudge or fingerprint churn rates are too high', () => {
|
||||||
|
const base = statusEvents(24);
|
||||||
|
const noisyEvents = [
|
||||||
|
...base,
|
||||||
|
...base
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((source, index) => event(100 + index, 'would_nudge', source.recordedAt)),
|
||||||
|
...base
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((source, index) => event(200 + index, 'fingerprint_changed', source.recordedAt)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const assessment = assessMemberWorkSyncPhase2Readiness({
|
||||||
|
memberCount: 1,
|
||||||
|
recentEvents: noisyEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.state).toBe('blocked');
|
||||||
|
expect(assessment.reasons).toContain('would_nudge_rate_high');
|
||||||
|
expect(assessment.reasons).toContain('fingerprint_churn_high');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -144,5 +144,12 @@ describe('JsonMemberWorkSyncStore', () => {
|
||||||
'status_evaluated',
|
'status_evaluated',
|
||||||
'fingerprint_changed',
|
'fingerprint_changed',
|
||||||
]);
|
]);
|
||||||
|
expect(metrics.phase2Readiness).toMatchObject({
|
||||||
|
state: 'collecting_shadow_data',
|
||||||
|
reasons: expect.arrayContaining([
|
||||||
|
'insufficient_status_events',
|
||||||
|
'insufficient_observation_window',
|
||||||
|
]),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue