1
0
Fork 0

feat(operations): drop attendance from readiness scoring

This commit is contained in:
Jason Fraley 2026-09-22 18:14:44 -04:00
parent b6a3226e3d
commit bfe26d76c1
3 changed files with 17 additions and 76 deletions

View file

@ -10,15 +10,14 @@
* *
* Sources (all post-settlement, never live/unsettled data): * Sources (all post-settlement, never live/unsettled data):
* - participation: applied `operation-effects` reached via their provenance * - participation: applied `operation-effects` reached via their provenance
* link (mission-attendance -> operation-events -> operation-effects). * link (operation-events -> operation-effects) for the subject's actor id.
* - attendance: `mission-attendances` "yes" for the subject.
* - assignments: `assignments` the subject belongs to. * - assignments: `assignments` the subject belongs to.
* - evaluation: metadata-only (performance level) aggregate of evaluations * - evaluation: metadata-only (performance level) aggregate of evaluations
* where the subject is the ratee. Comment/rater are never read. * where the subject is the ratee. Comment/rater are never read.
* - objectives: validated `operation.objective` events for the subject. * - objectives: validated `operation.objective` events for the subject.
* - logistics: completed `shipments` created by the subject. * - logistics: completed `shipments` created by the subject.
*/ */
import type { Payload, PayloadRequest, Where } from "payload"; import type { Payload, PayloadRequest } from "payload";
import type { OperationReadiness } from "@/payload-types"; import type { OperationReadiness } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { Permission } from "@/permissions"; import { Permission } from "@/permissions";
@ -54,15 +53,6 @@ export interface ReadinessReadResult {
data: unknown | null; data: unknown | null;
} }
function relationId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
const id = (value as { id: unknown }).id;
return typeof id === "number" ? id : null;
}
return null;
}
/** /**
* Gather normalized per-category inputs for a subject over a window. Every query * Gather normalized per-category inputs for a subject over a window. Every query
* is bounded and read-only; the evaluation path reads only the performance level * is bounded and read-only; the evaluation path reads only the performance level
@ -75,36 +65,17 @@ export async function gatherReadinessInputs(
): Promise<ReadinessInputs> { ): Promise<ReadinessInputs> {
const inWindow = { greater_than_equal: scope.windowStart.toISOString(), less_than_equal: scope.windowEnd.toISOString() }; const inWindow = { greater_than_equal: scope.windowStart.toISOString(), less_than_equal: scope.windowEnd.toISOString() };
const attended = await payload.find({ // Operation participation via provenance link: validated
collection: "mission-attendances", // operation-events -> applied operation-effects for the subject's actor id.
where: {
and: [
{ user: { equals: scope.subjectId } },
{ response: { equals: "yes" } },
{ createdAt: inWindow },
],
},
limit: 500,
depth: 0,
overrideAccess: true,
req,
});
const attendanceYes = attended.docs.length;
const attendedMissionIds = attended.docs
.map((d) => relationId((d as { mission: unknown }).mission))
.filter((id): id is number => id !== null);
// Operation participation via provenance link: mission -> validated
// operation-events -> applied operation-effects. The explicit `Where`
// annotation gives the ternary a contextual type, avoiding the
// discriminated-union-with-undefined-props shape Payload's Where rejects.
const eventsWhere: Where =
attendedMissionIds.length > 0
? { or: [{ actor: { equals: scope.subjectId } }, { mission: { in: attendedMissionIds } }] }
: { actor: { equals: scope.subjectId } };
const events = await payload.find({ const events = await payload.find({
collection: "operation-events", collection: "operation-events",
where: { and: [{ status: { equals: "validated" } }, { occurredAt: inWindow }, eventsWhere] }, where: {
and: [
{ status: { equals: "validated" } },
{ occurredAt: inWindow },
{ actor: { equals: scope.subjectId } },
],
},
limit: 1000, limit: 1000,
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
@ -131,9 +102,7 @@ export async function gatherReadinessInputs(
{ status: { equals: "validated" } }, { status: { equals: "validated" } },
{ type: { equals: "operation.objective" } }, { type: { equals: "operation.objective" } },
{ occurredAt: inWindow }, { occurredAt: inWindow },
attendedMissionIds.length > 0 { actor: { equals: scope.subjectId } },
? { or: [{ actor: { equals: scope.subjectId } }, { mission: { in: attendedMissionIds } }] }
: { actor: { equals: scope.subjectId } },
], ],
}, },
limit: 500, limit: 500,
@ -196,7 +165,6 @@ export async function gatherReadinessInputs(
return { return {
participation: countScore(READINESS_CATEGORIES.PARTICIPATION, participationCount, 25), participation: countScore(READINESS_CATEGORIES.PARTICIPATION, participationCount, 25),
attendance: countScore(READINESS_CATEGORIES.ATTENDANCE, attendanceYes, 34),
assignments: countScore(READINESS_CATEGORIES.ASSIGNMENTS, assignments.docs.length, 25), assignments: countScore(READINESS_CATEGORIES.ASSIGNMENTS, assignments.docs.length, 25),
evaluation: evaluation:
evaluationScore === null evaluationScore === null

View file

@ -39,7 +39,7 @@ export type ReadinessSubjectType = typeof READINESS_SUBJECT[keyof typeof READINE
/** /**
* Contributing categories and their declared weights. The weights are a fixed, * Contributing categories and their declared weights. The weights are a fixed,
* documented ratio (they sum to 130; the aggregate normalizes over the sum of * documented ratio (they sum to 110; the aggregate normalizes over the sum of
* the *present* categories' weights). They are NOT tunable per-subject, so the * the *present* categories' weights). They are NOT tunable per-subject, so the
* model stays transparent with no hidden weighting. The category name is the * model stays transparent with no hidden weighting. The category name is the
* only thing surfaced in the explanation -- never evaluator names, ids, or * only thing surfaced in the explanation -- never evaluator names, ids, or
@ -47,7 +47,6 @@ export type ReadinessSubjectType = typeof READINESS_SUBJECT[keyof typeof READINE
*/ */
export const READINESS_CATEGORIES = { export const READINESS_CATEGORIES = {
PARTICIPATION: "participation", PARTICIPATION: "participation",
ATTENDANCE: "attendance",
ASSIGNMENTS: "assignments", ASSIGNMENTS: "assignments",
EVALUATION: "evaluation", EVALUATION: "evaluation",
OBJECTIVES: "objectives", OBJECTIVES: "objectives",
@ -57,7 +56,6 @@ export type ReadinessCategory = typeof READINESS_CATEGORIES[keyof typeof READINE
export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = { export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = {
participation: 25, participation: 25,
attendance: 20,
assignments: 25, assignments: 25,
evaluation: 30, evaluation: 30,
objectives: 15, objectives: 15,
@ -67,7 +65,6 @@ export const READINESS_CATEGORY_WEIGHTS: Record<ReadinessCategory, number> = {
/** Human-readable label for a contributing category (explanation text only). */ /** Human-readable label for a contributing category (explanation text only). */
export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = { export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = {
participation: "Operation participation", participation: "Operation participation",
attendance: "Attendance",
assignments: "Assignments", assignments: "Assignments",
evaluation: "Leadership evaluation", evaluation: "Leadership evaluation",
objectives: "Completed objectives", objectives: "Completed objectives",
@ -79,7 +76,7 @@ export const READINESS_CATEGORY_LABELS: Record<ReadinessCategory, string> = {
* the signals commanders rely on when planning, distinct from the broader * the signals commanders rely on when planning, distinct from the broader
* readiness measure. * readiness measure.
*/ */
export const PLANNING_CATEGORIES: readonly ReadinessCategory[] = ["attendance", "assignments"]; export const PLANNING_CATEGORIES: readonly ReadinessCategory[] = ["assignments"];
/** Normalized per-category input fed to the pure computation. */ /** Normalized per-category input fed to the pure computation. */
export interface ReadinessCategoryInput { export interface ReadinessCategoryInput {
@ -93,7 +90,6 @@ export interface ReadinessCategoryInput {
export interface ReadinessInputs { export interface ReadinessInputs {
participation: ReadinessCategoryInput; participation: ReadinessCategoryInput;
attendance: ReadinessCategoryInput;
assignments: ReadinessCategoryInput; assignments: ReadinessCategoryInput;
evaluation: ReadinessCategoryInput; evaluation: ReadinessCategoryInput;
objectives: ReadinessCategoryInput; objectives: ReadinessCategoryInput;

View file

@ -16,7 +16,6 @@ const opEffectIds: number[] = [];
const userIds: number[] = []; const userIds: number[] = [];
const roleIds: number[] = []; const roleIds: number[] = [];
const missionIds: number[] = []; const missionIds: number[] = [];
const attendanceIds: number[] = [];
const evaluationIds: number[] = []; const evaluationIds: number[] = [];
const campaignIds: number[] = []; const campaignIds: number[] = [];
const mapIds: number[] = []; const mapIds: number[] = [];
@ -82,17 +81,6 @@ async function makeMission(label: string, authorId: number): Promise<number> {
return mission.id; return mission.id;
} }
async function makeAttendance(missionId: number, userId: number): Promise<number> {
const doc = await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: userId, response: "yes" },
overrideAccess: true,
depth: 0,
});
attendanceIds.push(doc.id);
return doc.id;
}
async function makeEvaluation(missionId: number, ratee: number): Promise<number> { async function makeEvaluation(missionId: number, ratee: number): Promise<number> {
const doc = await payload.create({ const doc = await payload.create({
collection: "evaluations", collection: "evaluations",
@ -229,9 +217,6 @@ afterAll(async () => {
for (const id of evaluationIds) { for (const id of evaluationIds) {
await payload.delete({ collection: "evaluations", id, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "evaluations", id, overrideAccess: true }).catch(() => {});
} }
for (const id of attendanceIds) {
await payload.delete({ collection: "mission-attendances", id, overrideAccess: true }).catch(() => {});
}
for (const id of missionIds) { for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
} }
@ -255,7 +240,6 @@ afterAll(async () => {
describe("Operation readiness", () => { describe("Operation readiness", () => {
it("computes a deterministic aggregate from window inputs and stores it with an explanation", async () => { it("computes a deterministic aggregate from window inputs and stores it with an explanation", async () => {
const mission = await makeMission("Alpha", adminId); const mission = await makeMission("Alpha", adminId);
await makeAttendance(mission, subjectId);
await makeEvaluation(mission, subjectId); await makeEvaluation(mission, subjectId);
await makeObjectiveEvent("obj-1", subjectId); await makeObjectiveEvent("obj-1", subjectId);
@ -272,20 +256,14 @@ describe("Operation readiness", () => {
readinessIds.push(result.id); readinessIds.push(result.id);
const categories = result.contributingCategories.map((c) => c.category); const categories = result.contributingCategories.map((c) => c.category);
expect(categories).toContain("attendance");
expect(categories).toContain("evaluation"); expect(categories).toContain("evaluation");
expect(categories).toContain("objectives"); expect(categories).toContain("objectives");
expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION); expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION);
expect(result.nature).toBe("advisory"); expect(result.nature).toBe("advisory");
const attendance = result.contributingCategories.find((c) => c.category === "attendance");
expect(attendance?.inputCount).toBe(1);
expect(attendance?.score).toBe(34);
const evaluation = result.contributingCategories.find((c) => c.category === "evaluation"); const evaluation = result.contributingCategories.find((c) => c.category === "evaluation");
expect(evaluation?.score).toBe(70); expect(evaluation?.score).toBe(70);
expect(result.explanation).toContain("Attendance");
expect(result.explanation).not.toContain(raterDisplayName()); expect(result.explanation).not.toContain(raterDisplayName());
expect(result.explanation).not.toContain("evaluator"); expect(result.explanation).not.toContain("evaluator");
}); });
@ -301,8 +279,7 @@ describe("Operation readiness", () => {
const scope = scopeFor(windowStart, windowEnd); const scope = scopeFor(windowStart, windowEnd);
const before = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number }; const before = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number };
const missions = missionIds; await makeObjectiveEvent("obj-2", subjectId);
await makeAttendance(missions[0], subjectId);
const after = (await computeReadiness(payload, scope)) as { const after = (await computeReadiness(payload, scope)) as {
id: number; id: number;
@ -310,8 +287,8 @@ describe("Operation readiness", () => {
contributingCategories: { category: string; inputCount: number }[]; contributingCategories: { category: string; inputCount: number }[];
}; };
expect(after.id).toBe(before.id); expect(after.id).toBe(before.id);
const attendance = after.contributingCategories.find((c) => c.category === "attendance"); const objectives = after.contributingCategories.find((c) => c.category === "objectives");
expect(attendance?.inputCount).toBe(2); expect(objectives?.inputCount).toBe(2);
expect(after.readinessScore).not.toBe(before.readinessScore); expect(after.readinessScore).not.toBe(before.readinessScore);
}); });