Skip to content

Commit 6ecc04a

Browse files
authored
Merge pull request #94 from ls1intum/code-smells
fix: correctness bugs in struggle detection pipeline
2 parents c696ea3 + 74e958b commit 6ecc04a

7 files changed

Lines changed: 950 additions & 18 deletions

File tree

CODE_SMELL_REPORT.md

Lines changed: 717 additions & 0 deletions
Large diffs are not rendered by default.

extension/src/extension/services/telemetry/decision/interventionDecisionEngine.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class InterventionDecisionEngine {
6969
}
7070

7171
// 2. Map EQ to intervention level
72-
const level = this._mapEQToLevel(eq);
72+
const level = this.mapEQToLevel(eq);
7373

7474
if (level === 'none') {
7575
return {
@@ -98,8 +98,9 @@ export class InterventionDecisionEngine {
9898

9999
/**
100100
* Map EQ score to recommended action level.
101+
* Single source of truth for EQ-to-action mapping.
101102
*/
102-
private _mapEQToLevel(eq: number): RecommendedAction {
103+
public mapEQToLevel(eq: number): RecommendedAction {
103104
if (eq >= this._thresholds.proactive) {
104105
return 'proactive';
105106
}

extension/src/extension/services/telemetry/eventPipeline/compileEquivalentEmitter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,13 @@ export function classifyBuildResult(result: ResultDTO): BuildResultClassificatio
252252
}
253253
}
254254

255-
// 3. Success
255+
// 3. Fallback: if server reports failure but buildFailed/testCases are missing,
256+
// treat as test-failure to avoid silent false-success classification
257+
if (result.successful === false) {
258+
return 'test-failure';
259+
}
260+
261+
// 4. Success
256262
return 'success';
257263
}
258264

extension/src/extension/services/telemetry/interventionFilter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export class InterventionFilter implements SessionResettable {
100100
*/
101101
public onSessionStart(_context: SessionStartContext): void {
102102
this.setExerciseStartTime();
103+
this._lastProgressTime = 0;
103104
}
104105

105106
}

extension/src/extension/services/telemetry/telemetryManager.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,6 @@ export class TelemetryManager implements vscode.Disposable, WebSocketMessageHand
384384
const decision = this._decisionEngine.evaluate(eq, confidence, triggerType, state);
385385

386386
if (!decision.shouldIntervene) {
387-
// Still show subtle hint if warranted
388-
if (decision.level === 'subtle') {
389-
this._interventionService.showSubtleHintEQ(decision);
390-
}
391387
return;
392388
}
393389

@@ -415,7 +411,7 @@ export class TelemetryManager implements vscode.Disposable, WebSocketMessageHand
415411
const level = this._getRecommendedAction(eq, confidence);
416412

417413
return {
418-
isStruggling: confidence !== 'insufficient' && eq >= 0.15,
414+
isStruggling: confidence !== 'insufficient' && level !== 'none',
419415
eq,
420416
eqConfidence: confidence,
421417
triggerType: this._lastTriggerType,
@@ -463,16 +459,7 @@ export class TelemetryManager implements vscode.Disposable, WebSocketMessageHand
463459
if (confidence === 'insufficient') {
464460
return 'none';
465461
}
466-
if (eq >= 0.60) {
467-
return 'proactive';
468-
}
469-
if (eq >= 0.35) {
470-
return 'notification';
471-
}
472-
if (eq >= 0.15) {
473-
return 'subtle';
474-
}
475-
return 'none';
462+
return this._decisionEngine.mapEQToLevel(eq);
476463
}
477464

478465
// ==================== DEBUG FEATURES ====================
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* classifyBuildResult — Unit Tests
3+
*
4+
* Verifies correct classification of Artemis build results into
5+
* compiler-error, test-failure, or success for EQ pipeline consumption.
6+
*
7+
* Covers the NEW-3 fix: result.successful === false fallback prevents
8+
* genuinely failed builds from being silently classified as 'success'.
9+
*/
10+
11+
import * as assert from 'assert';
12+
import { classifyBuildResult } from '../../../src/extension/services/telemetry/eventPipeline/compileEquivalentEmitter';
13+
import type { ResultDTO } from '../../../src/extension/domain/submissions';
14+
15+
function makeResult(overrides: Partial<ResultDTO> = {}): ResultDTO {
16+
return {
17+
id: 1,
18+
...overrides,
19+
};
20+
}
21+
22+
suite('classifyBuildResult', () => {
23+
24+
// ── Standard cases ──────────────────────────────────────────────
25+
26+
test('buildFailed === true → compiler-error', () => {
27+
const result = makeResult({ submission: { buildFailed: true } });
28+
assert.strictEqual(classifyBuildResult(result), 'compiler-error');
29+
});
30+
31+
test('test failures with counts → test-failure', () => {
32+
const result = makeResult({
33+
testCaseCount: 5,
34+
passedTestCaseCount: 3,
35+
submission: { buildFailed: false },
36+
});
37+
assert.strictEqual(classifyBuildResult(result), 'test-failure');
38+
});
39+
40+
test('all tests pass → success', () => {
41+
const result = makeResult({
42+
testCaseCount: 5,
43+
passedTestCaseCount: 5,
44+
successful: true,
45+
submission: { buildFailed: false },
46+
});
47+
assert.strictEqual(classifyBuildResult(result), 'success');
48+
});
49+
50+
test('no failure signals → success', () => {
51+
const result = makeResult({ successful: true });
52+
assert.strictEqual(classifyBuildResult(result), 'success');
53+
});
54+
55+
// ── NEW-3 fix: result.successful === false fallback ─────────────
56+
57+
test('successful === false with missing buildFailed and no test counts → test-failure (not success)', () => {
58+
const result = makeResult({
59+
successful: false,
60+
// submission.buildFailed is undefined
61+
// testCaseCount is undefined
62+
});
63+
assert.strictEqual(
64+
classifyBuildResult(result),
65+
'test-failure',
66+
'must not classify genuinely failed build as success when buildFailed field is missing',
67+
);
68+
});
69+
70+
test('successful === false with buildFailed === undefined and empty submission → test-failure', () => {
71+
const result = makeResult({
72+
successful: false,
73+
submission: { buildFailed: undefined },
74+
});
75+
assert.strictEqual(classifyBuildResult(result), 'test-failure');
76+
});
77+
78+
test('successful === undefined (truly unknown) with no other signals → success', () => {
79+
const result = makeResult({
80+
// successful is undefined
81+
// no buildFailed, no test counts
82+
});
83+
assert.strictEqual(
84+
classifyBuildResult(result),
85+
'success',
86+
'when server provides no success/failure signal, default to success',
87+
);
88+
});
89+
90+
// ── Priority: buildFailed takes precedence over successful ──────
91+
92+
test('buildFailed === true takes precedence over successful === false', () => {
93+
const result = makeResult({
94+
successful: false,
95+
submission: { buildFailed: true },
96+
});
97+
assert.strictEqual(classifyBuildResult(result), 'compiler-error');
98+
});
99+
100+
test('test counts take precedence over successful === false', () => {
101+
const result = makeResult({
102+
successful: false,
103+
testCaseCount: 10,
104+
passedTestCaseCount: 3,
105+
});
106+
assert.strictEqual(classifyBuildResult(result), 'test-failure');
107+
});
108+
});

extension/test/unit/struggle-detection/errorQuotientEngine.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,118 @@ suite('EQ Threshold Boundary Tests (InterventionDecisionEngine)', () => {
525525
});
526526
});
527527

528+
// =========================================================================
529+
// Guardrail Enforcement Tests (Fix Verification)
530+
// =========================================================================
531+
532+
suite('Guardrail Enforcement — Subtle Hints Respect Filter (NEW-1 fix)', () => {
533+
534+
let filter: InterventionFilter;
535+
let decisionEngine: InterventionDecisionEngine;
536+
537+
setup(() => {
538+
filter = new InterventionFilter();
539+
decisionEngine = new InterventionDecisionEngine(filter);
540+
});
541+
542+
test('subtle level blocked during exercise warmup (< 5 min)', () => {
543+
// Exercise just started — warmup not elapsed
544+
filter.setExerciseStartTime(Date.now());
545+
const state: InterventionState = {
546+
sessionInterventionCount: 0,
547+
lastInterventionTime: 0,
548+
lastDismissed: false,
549+
lastAccepted: false,
550+
};
551+
const result = decisionEngine.evaluate(0.20, 'sufficient', 'idle', state);
552+
assert.strictEqual(result.level, 'subtle');
553+
assert.strictEqual(result.shouldIntervene, false, 'subtle hint must be blocked during warmup');
554+
});
555+
556+
test('subtle level blocked when recent progress recorded', () => {
557+
filter.setExerciseStartTime(Date.now() - 10 * 60 * 1000); // 10 min ago
558+
filter.recordProgress(); // progress just now
559+
const state: InterventionState = {
560+
sessionInterventionCount: 0,
561+
lastInterventionTime: 0,
562+
lastDismissed: false,
563+
lastAccepted: false,
564+
};
565+
const result = decisionEngine.evaluate(0.20, 'sufficient', 'idle', state);
566+
assert.strictEqual(result.level, 'subtle');
567+
assert.strictEqual(result.shouldIntervene, false, 'subtle hint must be blocked during progress grace period');
568+
});
569+
570+
test('subtle level blocked after dismiss', () => {
571+
filter.setExerciseStartTime(Date.now() - 10 * 60 * 1000);
572+
const state: InterventionState = {
573+
sessionInterventionCount: 0,
574+
lastInterventionTime: 0,
575+
lastDismissed: true,
576+
lastAccepted: false,
577+
};
578+
const result = decisionEngine.evaluate(0.20, 'sufficient', 'idle', state);
579+
assert.strictEqual(result.level, 'subtle');
580+
assert.strictEqual(result.shouldIntervene, false, 'subtle hint must be blocked after dismiss');
581+
});
582+
583+
test('EQ below 0.15 produces no intervention at all', () => {
584+
filter.setExerciseStartTime(Date.now() - 10 * 60 * 1000);
585+
const state: InterventionState = {
586+
sessionInterventionCount: 0,
587+
lastInterventionTime: 0,
588+
lastDismissed: false,
589+
lastAccepted: false,
590+
};
591+
const result = decisionEngine.evaluate(0.10, 'sufficient', 'idle', state);
592+
assert.strictEqual(result.level, 'none');
593+
assert.strictEqual(result.shouldIntervene, false);
594+
});
595+
});
596+
597+
suite('InterventionFilter Session Isolation (NEW-4 fix)', () => {
598+
599+
let filter: InterventionFilter;
600+
let decisionEngine: InterventionDecisionEngine;
601+
602+
const defaultState: InterventionState = {
603+
sessionInterventionCount: 0,
604+
lastInterventionTime: 0,
605+
lastDismissed: false,
606+
lastAccepted: false,
607+
};
608+
609+
setup(() => {
610+
filter = new InterventionFilter();
611+
decisionEngine = new InterventionDecisionEngine(filter);
612+
});
613+
614+
test('progress from session A does not suppress interventions in session B', () => {
615+
// Session A: exercise running > 5min, student makes progress
616+
filter.setExerciseStartTime(Date.now() - 10 * 60 * 1000);
617+
filter.recordProgress();
618+
619+
// Verify: progress grace period is active
620+
const resultA = decisionEngine.evaluate(0.20, 'sufficient', 'idle', defaultState);
621+
assert.strictEqual(resultA.shouldIntervene, false, 'session A should be suppressed by progress');
622+
623+
// Session B starts
624+
filter.onSessionStart({ exerciseId: 999 });
625+
// Fast-forward past warmup
626+
(filter as any)._exerciseStartTime = Date.now() - 10 * 60 * 1000;
627+
628+
// Verify: progress grace period from session A is gone
629+
const resultB = decisionEngine.evaluate(0.20, 'sufficient', 'idle', defaultState);
630+
assert.strictEqual(resultB.shouldIntervene, true, 'session B must not be affected by session A progress');
631+
});
632+
633+
test('_lastProgressTime is 0 after onSessionStart', () => {
634+
filter.recordProgress();
635+
filter.onSessionStart({ exerciseId: 123 });
636+
assert.strictEqual((filter as any)._lastProgressTime, 0);
637+
});
638+
});
639+
528640
// =========================================================================
529641
// Helpers
530642
// =========================================================================

0 commit comments

Comments
 (0)