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 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.
|
||||
|
||||
Patterns used:
|
||||
|
|
@ -2981,6 +2982,7 @@ Check:
|
|||
- stale report rate;
|
||||
- invalid caught-up attempts;
|
||||
- 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,45 @@ export interface MemberWorkSyncTeamMetrics {
|
|||
reportAcceptedCount: number;
|
||||
reportRejectedCount: number;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts';
|
||||
import { assessMemberWorkSyncPhase2Readiness } from '../domain';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics {
|
||||
|
|
@ -20,6 +21,10 @@ function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeam
|
|||
reportAcceptedCount: 0,
|
||||
reportRejectedCount: 0,
|
||||
recentEvents: [],
|
||||
phase2Readiness: assessMemberWorkSyncPhase2Readiness({
|
||||
memberCount: 0,
|
||||
recentEvents: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +35,15 @@ export class MemberWorkSyncMetricsReader {
|
|||
if (!this.deps.statusStore.readTeamMetrics) {
|
||||
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 './currentReviewCycle';
|
||||
export * from './memberName';
|
||||
export * from './MemberWorkSyncPhase2Readiness';
|
||||
export * from './MemberWorkSyncReportValidator';
|
||||
export * from './SyncDecisionPolicy';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
MemberWorkSyncStatusState,
|
||||
MemberWorkSyncTeamMetrics,
|
||||
} from '../../contracts';
|
||||
import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain';
|
||||
import type {
|
||||
MemberWorkSyncReportStorePort,
|
||||
MemberWorkSyncStatusStorePort,
|
||||
|
|
@ -234,7 +235,7 @@ export class JsonMemberWorkSyncStore
|
|||
const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) =>
|
||||
left.recordedAt.localeCompare(right.recordedAt)
|
||||
);
|
||||
return {
|
||||
const metrics = {
|
||||
teamName,
|
||||
generatedAt: new Date().toISOString(),
|
||||
memberCount: members.length,
|
||||
|
|
@ -250,6 +251,13 @@ export class JsonMemberWorkSyncStore
|
|||
.length,
|
||||
recentEvents,
|
||||
};
|
||||
return {
|
||||
...metrics,
|
||||
phase2Readiness: assessMemberWorkSyncPhase2Readiness({
|
||||
memberCount: metrics.memberCount,
|
||||
recentEvents: metrics.recentEvents,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
'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