Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions backend/src/services/schedulerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ export interface WorkloadJob {
commandOrImage: string;
isContainerImage: boolean;
durationHours: number;
/** Deadline in hours after the forecast start; may be fractional (exact instant). */
deadlineHours: number;
arrivalHour: number;
/**
* P8: the job arrives this many hours after the start of slot `arrivalHour` (0 <= x < 1). A job
* starting in the arrival slot starts at the arrival instant, not at the slot start, so its
* deadline risk is computed from that instant.
*/
arrivalOffsetHours?: number;
cpu: number;
memoryMb: number;
region: string;
Expand Down Expand Up @@ -210,6 +217,10 @@ export class SchedulerService {
if (!Number.isInteger(arrival) || arrival < 0) throw new Error('arrivalHour must be a non-negative whole number.');
const deadline = Number(job.deadlineHours);
if (!Number.isFinite(deadline)) throw new Error('deadlineHours must be a number.');
const arrivalOffset = Number(job.arrivalOffsetHours ?? 0);
if (!(arrivalOffset >= 0 && arrivalOffset < 1)) throw new Error('arrivalOffsetHours must be in [0, 1).');
/** Actual start instant (hours after the forecast start) of a window that starts in slot t. */
const startOf = (t: number) => (t === arrival ? arrival + arrivalOffset : t);
const tau = Number(job.riskTolerance);
if (!(tau > 0 && tau < 1)) throw new Error('riskTolerance must be between 0 and 1 (exclusive).');
const cvInput = opts.runtimeCv ?? job.runtimeCv ?? 0;
Expand Down Expand Up @@ -250,8 +261,8 @@ export class SchedulerService {
const candidates: (CandidateWindowEvaluation & { exactAvg: number | null })[] = [];
for (let t = arrival; t <= lastStart; t++) {
const { avg, sigma } = windowStats(t);
const end = t + dur;
const risk = UncertaintyService.deadlineRisk(t, dur, deadline, runtimeCv).violationRisk;
const end = startOf(t) + dur;
const risk = UncertaintyService.deadlineRisk(startOf(t), dur, deadline, runtimeCv).violationRisk;
const slack = deadline - end;
let classification: WindowClassification;
let reason: string;
Expand Down Expand Up @@ -324,7 +335,7 @@ export class SchedulerService {
if (gapFree.length === 0) {
const reason =
candidates.length === 0
? `No start slot exists: a ${dur} h job arriving at slot ${arrival} cannot finish by slot ${deadline} within the ${points.length}-hour forecast.`
? `No start slot exists: a ${dur} h job arriving at slot ${arrival} cannot finish by T+${Number(deadline.toFixed(2))} h within the ${points.length}-hour forecast.`
: 'Every candidate window contains missing forecast data; nothing is filled in.';
return { ...common(), status: 'NO_FEASIBLE_WINDOW', reason, recommendedDecision: null };
}
Expand Down
7 changes: 6 additions & 1 deletion backend/src/services/schedulingWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,11 @@ export async function scheduleWorkflow(
const coverageEnd = Date.parse(points[points.length - 1].timestamp) + HOUR;

const arrivalHour = Math.max(0, Math.floor((arrival - start) / HOUR));
const deadlineHoursFromStart = Math.floor((deadline - start) / HOUR);
// P8: exact instants. Flooring the deadline made a window ending before it look as if it had no
// slack (50 % risk at cv > 0), and measuring the arrival slot from the start of its hour made an
// immediate start look safer than it is.
const deadlineHoursFromStart = (deadline - start) / HOUR;
const arrivalOffsetHours = Math.min(0.999999, Math.max(0, (arrival - start) / HOUR - arrivalHour));
if (arrivalHour + dur > points.length) {
return infeasible(
`The forecast only covers until ${iso(coverageEnd)}, which is too short to evaluate even an immediate ${dur} h run.`,
Expand All @@ -213,6 +217,7 @@ export async function scheduleWorkflow(
durationHours: dur,
deadlineHours: deadlineHoursFromStart,
arrivalHour,
arrivalOffsetHours,
cpu: input.cpu,
memoryMb: input.memoryMb,
region: zone,
Expand Down
3 changes: 2 additions & 1 deletion backend/test/collector/collectorLock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,8 @@ describe('every entry point uses the same lock', () => {
return /collectOnce\(/.test(t) || /collection-\$\{/.test(t);
});
expect(offenders).toEqual([]);
const self = fs.readFileSync(path.join(src, 'scripts', 'collectElectricityMaps.ts'), 'utf8');
// Normalise line endings: Windows checkouts use CRLF (git autocrlf), CI uses LF.
const self = fs.readFileSync(path.join(src, 'scripts', 'collectElectricityMaps.ts'), 'utf8').replace(/\r\n/g, '\n');
expect(self.match(/appendFileSync/g)).toHaveLength(1);
expect(self).toMatch(/if \(!lock\.isOwner\(\)\) return \{[^}]*lost: true \};\n\s*fs\.appendFileSync/);
});
Expand Down
118 changes: 118 additions & 0 deletions backend/test/scheduling/exactInstants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* P8 scheduling correctness: deadline risk must use the exact deadline and arrival instants.
*
* Before P8 the workflow floored the deadline to a whole forecast hour and measured a window in the
* arrival slot from the START of that hour:
* - A (over-conservative): a window ending 32 min before the deadline was treated as having zero
* slack (50 % miss risk at cv > 0) and was rejected;
* - B (unsafe, masked by A): starting at once was measured from the start of the arrival hour, so a
* window that really ends 5 min before the deadline would look like it had 65 min of slack.
* Fixing A alone would have made B recommend a window whose true risk (~34 %) exceeds tau.
* The forecast here is a fixed test fixture (descending intensities, so later windows are cleaner).
*/
import { describe, it, expect } from 'vitest';
import os from 'os';
import fs from 'fs';
import path from 'path';
import { scheduleWorkflow } from '../../src/services/schedulingWorkflow';
import { SchedulingStore } from '../../src/models/schedulingStore';
import { ElectricityMapsDataProvider } from '../../src/services/carbonService';
import { SchedulerService } from '../../src/services/schedulerService';
import { normalCdf } from '../../src/services/uncertaintyService';

const H = 3600_000;
const START = Date.UTC(2026, 10, 13, 13); // forecast starts 13:00 UTC

function snapshot() {
return {
provider: 'electricitymaps',
dataClass: 'real',
zone: 'DE',
issuedAt: new Date(START).toISOString(),
fetchedAt: new Date(START + 30 * 60_000).toISOString(),
horizonHours: 24,
granularity: 'hourly',
emissionFactorType: 'lifecycle',
unit: 'gCO2eq/kWh',
points: Array.from({ length: 24 }, (_, i) => ({ datetime: new Date(START + i * H).toISOString(), carbonIntensity: 400 - 10 * i })),
endpoint: '/carbon-intensity/forecast?zone=DE',
rawSha256: 'fixture',
};
}

async function run(arrivalMin: number, deadlineMin: number, extra: Record<string, unknown> = {}) {
const store = new SchedulingStore(path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'cr-exact-')), 's.json'));
const provider = new ElectricityMapsDataProvider(undefined, { getForecast: async () => snapshot() } as any);
const now = START + arrivalMin * 60_000;
return (await scheduleWorkflow(
{
name: 'exact',
commandOrImage: 'busybox',
isContainerImage: true,
zone: 'DE',
durationHours: 2,
cpu: 1,
memoryMb: 128,
riskTolerance: 0.1,
runtimeCv: 0.1,
arrivalAt: new Date(now).toISOString(),
deadlineAt: new Date(START + deadlineMin * 60_000).toISOString(),
mode: 'live',
...extra,
},
{ now: () => now, store, getForecast: async (z: string, h: number) => provider.getForecast(z, h) }
)) as any;
}

describe('deadline risk uses exact instants (P8)', () => {
it('A: a window that ends 32 min before the deadline is not treated as having zero slack', async () => {
// Arrival 13:32, deadline 18:32, 2 h job, cv 0.1 (sigma 12 min).
const r = await run(32, 5 * 60 + 32);
expect(r.status).toBe('RECOMMENDED');
expect(r.recommendedWindow).toEqual({ startUtc: '2026-11-13T16:00:00.000Z', endUtc: '2026-11-13T18:00:00.000Z' });
const cr = r.evaluatedPolicies.find((p: any) => p.policyId === 'carbonroute_uncertainty');
// P(miss) = 1 - Phi(32 min / 12 min)
expect(cr.estimatedDeadlineRisk).toBeCloseTo(1 - normalCdf(32 / 12), 6);
expect(cr.estimatedDeadlineRisk).toBeLessThan(0.01);
});

it('B: starting at once is measured from the arrival instant, so a window that really ends 5 min before the deadline is too risky', async () => {
// Arrival 13:40, deadline 15:45: the only window is 13:40-15:40 (5 min slack, sigma 12 min).
const r = await run(40, 2 * 60 + 45);
expect(r.status).toBe('INFEASIBLE_RISK');
const only = r.candidateWindows[0];
const expected = 1 - normalCdf(5 / 12); // ~0.34
expect(only.deadlineRisk).toBeCloseTo(expected, 6);
expect(only.deadlineRisk).toBeGreaterThan(0.3);
});

it('B with enough tolerance: the same window is recommended and its reported risk is the true one', async () => {
const r = await run(40, 2 * 60 + 45, { riskTolerance: 0.4 });
expect(r.status).toBe('RECOMMENDED');
expect(r.recommendedWindow.startUtc).toBe('2026-11-13T13:40:00.000Z');
const cr = r.evaluatedPolicies.find((p: any) => p.policyId === 'carbonroute_uncertainty');
expect(cr.estimatedDeadlineRisk).toBeCloseTo(1 - normalCdf(5 / 12), 6);
});

it('deterministic runtime (cv 0): a window is feasible exactly when it ends by the deadline instant', async () => {
const ok = await run(40, 2 * 60 + 41, { runtimeCv: 0 }); // 13:40-15:40 ends 1 min early
expect(ok.status).toBe('RECOMMENDED');
const late = await run(40, 2 * 60 + 39, { runtimeCv: 0 }); // would end 1 min late
expect(late.status).not.toBe('RECOMMENDED');
});

it('whole-hour arrival and deadline behave exactly as before (no offset, no fractional slack)', async () => {
// Arrival 13:00, deadline 21:00 (8 h). 19:00-21:00 ends exactly at the deadline: zero slack, 50 % at
// cv 0.1, rejected; 18:00-20:00 has 1 h slack (risk ~0) and is the cleanest feasible window.
const r = await run(0, 8 * 60);
expect(r.status).toBe('RECOMMENDED');
expect(r.recommendedWindow.startUtc).toBe('2026-11-13T18:00:00.000Z');
const last = r.candidateWindows.find((c: any) => c.startUtc === '2026-11-13T19:00:00.000Z');
expect(last.deadlineRisk).toBeCloseTo(0.5, 6);
});

it('rejects an invalid arrival offset', () => {
const job = { id: 'j', name: 'j', commandOrImage: 'x', isContainerImage: false, durationHours: 1, deadlineHours: 5, arrivalHour: 0, arrivalOffsetHours: 1, cpu: 1, memoryMb: 128, region: 'TST', riskTolerance: 0.1, createdAt: 'x' };
expect(() => SchedulerService.evaluatePolicies(job as any, [100, 100, 100, 100, 100, 100] as any, {})).toThrow(/arrivalOffsetHours/);
});
});
7 changes: 7 additions & 0 deletions docs/SCHEDULING_METHODOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ time, so it is not part of the deadline risk. The runtime is modelled as
$R \sim \mathcal{N}(d, (cv \cdot d)^2)$, giving
$$P_{\text{miss}}(t) = P(t + R > D) = 1 - \Phi\!\left(\frac{D - t - d}{cv \cdot d}\right).$$

- **Exact instants (P8).** $D$ is the exact deadline in hours after the forecast start (fractional,
not rounded to a slot), and $t$ is the window's **actual** start: the arrival instant for a window in
the arrival slot, otherwise the slot start. Before P8 the deadline was floored to a whole hour (a
window ending 32 min before the deadline was treated as having no slack) and the arrival slot was
measured from the start of its hour (an immediate start looked up to an hour safer than it was).
Regression tests: `backend/test/scheduling/exactInstants.test.ts`. Benchmark replays use whole hours
and are unaffected.
- $cv = 0$ (the default): deterministic, $P_{\text{miss}} = 0$ if $t + d \le D$, otherwise 1.
- $cv$ is **declared by the user**; it is not learned. Empirical runtime distributions need recorded
executions (roadmap M9.1). Start delays are not modelled.
Expand Down
Loading