feat(member-work-sync): assess phase two readiness

This commit is contained in:
777genius 2026-04-29 15:07:19 +03:00
parent 440548531b
commit c5a97fd796
8 changed files with 281 additions and 2 deletions

View file

@ -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:

View file

@ -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 {

View file

@ -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,
}),
};
}
}

View file

@ -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}`),
};
}

View file

@ -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';

View file

@ -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> {

View file

@ -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');
});
});

View file

@ -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',
]),
});
});
});