Skip to content

Commit 992737e

Browse files
tzacks-clclaude
andcommitted
feat: Default to first attempts and improve threshold calculation
- Change default mode from all attempts to unique students (first attempts only) for more meaningful educational analysis - Add selected sequence filtering to only count students who took exact paths - Improve minimum threshold calculation with predecessor validation to prevent orphaning of intermediate nodes when applying filters - Fix student visit statistics to show mathematically possible counts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a091724 commit 992737e

File tree

3 files changed

+98
-7
lines changed

3 files changed

+98
-7
lines changed

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function App() {
7474
// State to toggle whether self-loops (transitions back to the same node) should be included
7575
const [selfLoops, setSelfLoops] = useState<boolean>(true);
7676
const [errorMode, setErrorMode] = useState<boolean>(false);
77-
const [uniqueStudentMode, setUniqueStudentMode] = useState<boolean>(false);
77+
const [uniqueStudentMode, setUniqueStudentMode] = useState<boolean>(true);
7878
const [fileInfo, setFileInfo] = useState<{filename: string, source: string} | null>(null);
7979
// State to manage the minimum number of visits for displaying edges in the graph
8080
const [minVisitsPercentage, setMinVisitsPercentage] = useState<number>(0);

src/components/GraphvizParent.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ interface HistoryItem {
3131

3232
const titleCase = (str: string | null) => str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : '';
3333

34+
// Helper function to compare arrays for exact equality
35+
const arraysEqual = (a: string[], b: string[]): boolean => {
36+
if (a.length !== b.length) return false;
37+
return a.every((val, index) => val === b[index]);
38+
};
39+
3440
interface GraphvizParentProps {
3541
csvData: string;
3642
filter: string | null;
@@ -431,12 +437,30 @@ const GraphvizParent: React.FC<GraphvizParentProps> = ({
431437
// Calculate statistics
432438
let totalVisitors = 0;
433439
let totalNodeVisits = 0;
440+
const visitCounts: { [studentId: string]: number } = {};
441+
442+
// Check if this is the selected sequence graph and we need to filter
443+
const isSelectedSequenceGraph = graphType === 'Selected Sequence';
444+
const sequenceToFilter = isSelectedSequenceGraph ? selectedSequence : null;
434445

435446
if (stepSequences && Object.keys(stepSequences).length > 0) {
436-
const visitCounts: { [studentId: string]: number } = {};
437447
Object.entries(stepSequences).forEach(([studentId, studentProblems]) => {
438448
// studentProblems is { [problemName]: string[] }
439449
if (studentProblems && typeof studentProblems === 'object') {
450+
451+
// For selected sequence graph, check if student took the exact path
452+
if (isSelectedSequenceGraph && sequenceToFilter) {
453+
let studentFollowedSequence = false;
454+
Object.values(studentProblems).forEach((problemSequence: string[]) => {
455+
if (Array.isArray(problemSequence) && arraysEqual(problemSequence, sequenceToFilter)) {
456+
studentFollowedSequence = true;
457+
}
458+
});
459+
if (!studentFollowedSequence) {
460+
return; // Skip this student if they didn't follow the exact sequence
461+
}
462+
}
463+
440464
let studentNodeVisits = 0;
441465
Object.values(studentProblems).forEach((problemSequence: string[]) => {
442466
if (Array.isArray(problemSequence)) {
@@ -455,9 +479,22 @@ const GraphvizParent: React.FC<GraphvizParentProps> = ({
455479
}
456480

457481
const avgVisitsPerStudent = totalVisitors > 0 ? (totalNodeVisits / totalVisitors).toFixed(1) : '0';
458-
const studentsWithRepeats = Math.max(0, totalNodeVisits - totalVisitors);
459-
const singleVisits = Math.max(0, totalVisitors - studentsWithRepeats);
460-
const multipleVisits = studentsWithRepeats;
482+
483+
// Calculate correct student visit statistics
484+
let studentsWithSingleVisit = 0;
485+
let studentsWithMultipleVisits = 0;
486+
487+
Object.values(visitCounts).forEach((visitCount: number) => {
488+
if (visitCount > 1) {
489+
studentsWithMultipleVisits++;
490+
} else {
491+
studentsWithSingleVisit++;
492+
}
493+
});
494+
495+
496+
const singleVisits = studentsWithSingleVisit;
497+
const multipleVisits = studentsWithMultipleVisits;
461498

462499
// Count unique edges that meet the minimum visits threshold
463500
const filteredIncomingEdges = incomingEdges.filter(edge => (edgeCounts[edge] || 0) >= minVisits);

src/components/GraphvizProcessing.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -987,12 +987,15 @@ export function calculateMaxMinEdgeCount(
987987
// Additional check: ensure selected sequence remains connected
988988
const isSequenceConnected = checkSequenceConnectivity(validEdges, selectedSequence);
989989

990-
if (isGraphConnected && isSequenceConnected) {
990+
// Additional check: ensure non-first nodes have preceding nodes
991+
const hasValidPredecessors = checkNodePredecessors(validEdges, allNodes, selectedSequence);
992+
993+
if (isGraphConnected && isSequenceConnected && hasValidPredecessors) {
991994
maxValidThreshold = threshold;
992995
console.log(`✓ Threshold ${threshold} keeps all nodes and sequence connected`);
993996
break; // Since we're going in descending order, this is the maximum valid threshold
994997
} else {
995-
console.log(`✗ Threshold ${threshold} disconnects nodes (graph: ${isGraphConnected}, sequence: ${isSequenceConnected})`);
998+
console.log(`✗ Threshold ${threshold} disconnects nodes (graph: ${isGraphConnected}, sequence: ${isSequenceConnected}, predecessors: ${hasValidPredecessors})`);
996999
}
9971000
}
9981001

@@ -1047,6 +1050,57 @@ function checkSequenceConnectivity(
10471050
return true;
10481051
}
10491052

1053+
/**
1054+
* Ensures that nodes which aren't first nodes in any path still have at least one preceding node.
1055+
* This prevents orphaning of intermediate nodes when applying threshold filters.
1056+
*
1057+
* @param edges - Available edges with their counts
1058+
* @param allNodes - All nodes in the graph
1059+
* @param selectedSequence - The selected sequence (first node is considered a valid starting point)
1060+
* @returns True if all non-first nodes have at least one incoming edge
1061+
*/
1062+
function checkNodePredecessors(
1063+
edges: Array<{edge: string, count: number}>,
1064+
allNodes: Set<string>,
1065+
selectedSequence: string[]
1066+
): boolean {
1067+
// Build incoming edge map
1068+
const incomingEdges = new Map<string, Set<string>>();
1069+
1070+
// Initialize all nodes with empty sets
1071+
allNodes.forEach(node => {
1072+
incomingEdges.set(node, new Set());
1073+
});
1074+
1075+
// Track incoming edges for each node
1076+
edges.forEach(({ edge }) => {
1077+
const [fromNode, toNode] = edge.split('->');
1078+
if (fromNode && toNode) {
1079+
incomingEdges.get(toNode)?.add(fromNode);
1080+
}
1081+
});
1082+
1083+
// Get the first node in the selected sequence (this can be without predecessors)
1084+
const firstSequenceNode = selectedSequence.length > 0 ? selectedSequence[0] : null;
1085+
1086+
// Check that all nodes (except potential first nodes) have at least one predecessor
1087+
for (const node of allNodes) {
1088+
const hasIncomingEdges = (incomingEdges.get(node)?.size ?? 0) > 0;
1089+
1090+
// Allow nodes without predecessors only if they're the first in the selected sequence
1091+
// or if they could be legitimate starting points
1092+
if (!hasIncomingEdges && node !== firstSequenceNode) {
1093+
// Additional check: see if this node appears as first in any actual student path
1094+
// For now, we'll be conservative and require all non-first-sequence nodes to have predecessors
1095+
console.log(`Node ${node} has no predecessors and isn't the first in selected sequence`);
1096+
return false;
1097+
}
1098+
}
1099+
1100+
console.log("All nodes have valid predecessors or are legitimate starting points");
1101+
return true;
1102+
}
1103+
10501104
/**
10511105
* Checks if a graph is connected (all nodes can reach each other) given a set of edges.
10521106
* Uses depth-first search to verify connectivity.

0 commit comments

Comments
 (0)