Skip to content

feat(stepper): implement PythonStepperEvaluator and add stepper dependencies#152

Open
Shrey5132 wants to merge 1 commit into
source-academy:mainfrom
Shrey5132:feat/conductor-stepper
Open

feat(stepper): implement PythonStepperEvaluator and add stepper dependencies#152
Shrey5132 wants to merge 1 commit into
source-academy:mainfrom
Shrey5132:feat/conductor-stepper

Conversation

@Shrey5132

Copy link
Copy Markdown

This PR adds portal dependencies on @sourceacademy/common-stepper and @sourceacademy/runner-stepper from the plugins repository. It implements PythonStepperEvaluator1 and its supporting AST walking, translation, and state reduction logic to step through Python 1 code using the Conductor framework.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a Python substitution stepper evaluator (PythonStepperEvaluator1) and its associated runner plugin to drive the stepper in the Conductor runner. It includes AST translation to an estree-shaped node model, a substitution-model reducer, and comprehensive tests. Feedback on the implementation highlights two key issues in the reducer: first, the substitute function incorrectly substitutes the identifier of a VariableDeclarator instead of only its init expression, which can corrupt the AST; second, division/modulo by zero and raising zero to a negative power are not handled, which can propagate JS Infinity or NaN instead of throwing a Python-like ZeroDivisionError.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +84 to +97
export function substitute(node: StepNode, name: string, value: StepNode): StepNode {
switch (node.type) {
case 'Identifier':
return node.name === name ? clone(value) : node;
case 'ArrowFunctionExpression':
if (paramNames(node).includes(name)) return node;
return { ...node, body: substitute(node.body as StepNode, name, value) };
case 'FunctionDeclaration':
if ((node.id as StepNode).name === name || paramNames(node).includes(name)) return node;
return { ...node, body: substitute(node.body as StepNode, name, value) };
default:
return mapChildren(node, child => substitute(child, name, value));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When substituting a variable name, the recursive substitution model falls back to the default case for VariableDeclarator nodes. This causes the id (the variable name identifier) of the declarator to be substituted with its value, resulting in a malformed AST (e.g., 5 = 6 instead of x = 6) and potential runtime crashes or unexpected behavior when evaluating subsequent re-bindings.

Adding an explicit case for VariableDeclarator to only substitute its init expression prevents this issue and ensures AST integrity.

Suggested change
export function substitute(node: StepNode, name: string, value: StepNode): StepNode {
switch (node.type) {
case 'Identifier':
return node.name === name ? clone(value) : node;
case 'ArrowFunctionExpression':
if (paramNames(node).includes(name)) return node;
return { ...node, body: substitute(node.body as StepNode, name, value) };
case 'FunctionDeclaration':
if ((node.id as StepNode).name === name || paramNames(node).includes(name)) return node;
return { ...node, body: substitute(node.body as StepNode, name, value) };
default:
return mapChildren(node, child => substitute(child, name, value));
}
}
export function substitute(node: StepNode, name: string, value: StepNode): StepNode {
switch (node.type) {
case 'Identifier':
return node.name === name ? clone(value) : node;
case 'ArrowFunctionExpression':
if (paramNames(node).includes(name)) return node;
return { ...node, body: substitute(node.body as StepNode, name, value) };
case 'FunctionDeclaration':
if ((node.id as StepNode).name === name || paramNames(node).includes(name)) return node;
return { ...node, body: substitute(node.body as StepNode, name, value) };
case 'VariableDeclarator':
return { ...node, init: substitute(node.init as StepNode, name, value) };
default:
return mapChildren(node, child => substitute(child, name, value));
}
}

Comment on lines +114 to +123
} else if (typeof l === 'number' && typeof r === 'number') {
const pyFloat = Boolean(left.pyFloat) || Boolean(right.pyFloat) || op === '/';
switch (op) {
case '+': result = numberLiteral(l + r, pyFloat); break;
case '-': result = numberLiteral(l - r, pyFloat); break;
case '*': result = numberLiteral(l * r, pyFloat); break;
case '/': result = numberLiteral(l / r, true); break;
case '//': result = numberLiteral(Math.floor(l / r), pyFloat); break;
case '%': result = numberLiteral(((l % r) + r) % r, pyFloat); break;
case '**': result = numberLiteral(l ** r, pyFloat || l ** r !== Math.floor(l ** r)); break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The stepper does not currently handle division or modulo by zero, or raising zero to a negative power. In JavaScript, these operations result in Infinity or NaN, which can propagate through the reduction steps and cause unexpected behavior or crashes in the host renderer. Adding explicit checks to throw a ZeroDivisionError matches Python's semantics and allows the evaluator to catch and report the error gracefully.

Suggested change
} else if (typeof l === 'number' && typeof r === 'number') {
const pyFloat = Boolean(left.pyFloat) || Boolean(right.pyFloat) || op === '/';
switch (op) {
case '+': result = numberLiteral(l + r, pyFloat); break;
case '-': result = numberLiteral(l - r, pyFloat); break;
case '*': result = numberLiteral(l * r, pyFloat); break;
case '/': result = numberLiteral(l / r, true); break;
case '//': result = numberLiteral(Math.floor(l / r), pyFloat); break;
case '%': result = numberLiteral(((l % r) + r) % r, pyFloat); break;
case '**': result = numberLiteral(l ** r, pyFloat || l ** r !== Math.floor(l ** r)); break;
} else if (typeof l === 'number' && typeof r === 'number') {
if (r === 0 && (op === '/' || op === '//' || op === '%')) {
throw new Error('ZeroDivisionError: division by zero');
}
if (l === 0 && r < 0 && op === '**') {
throw new Error('ZeroDivisionError: 0.0 cannot be raised to a negative power');
}
const pyFloat = Boolean(left.pyFloat) || Boolean(right.pyFloat) || op === '/';
switch (op) {
case '+': result = numberLiteral(l + r, pyFloat); break;
case '-': result = numberLiteral(l - r, pyFloat); break;
case '*': result = numberLiteral(l * r, pyFloat); break;
case '/': result = numberLiteral(l / r, true); break;
case '//': result = numberLiteral(Math.floor(l / r), pyFloat); break;
case '%': result = numberLiteral(((l % r) + r) % r, pyFloat); break;
case '**': result = numberLiteral(l ** r, pyFloat || l ** r !== Math.floor(l ** r)); break;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, please take a look at the existing operator combinations, Python has weird properties (e.g., the pyMod function)

It doesn't work exactly the same as JS

@AaravMalani AaravMalani left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've not checked much of the actual reduction, but there a few minor and big changes I'm requesting. Good work anyways!

Comment thread scripts/build.ts
"PyWasmEvaluator",
"PySvmlEvaluator",
"PySvmlSinterEvaluator",
"PythonStepperEvaluator1",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename to PyStepperEvaluator1 for naming convention sake?

Comment thread package.json
"typescript-eslint": "^8.56.1"
},
"dependencies": {
"@sourceacademy/common-stepper": "portal:../plugins/src/common/stepper",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm making it a draft till it gets added to the plugins repository

return identifier(p.lexeme);
}

function translateExpr(expr: ExprNS.Expr): StepNode {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have a translation layer? Wouldn't it save a lot of code just by directly operating on the normal AST nodes? (Don't sweat it if it's not feasible, it's pretty minor and I'll still approve without it)

function translateExpr(expr: ExprNS.Expr): StepNode {
switch (expr.kind) {
case 'BigIntLiteral': {
// Python ints are arbitrary-precision; the stepper computes in JS `number`, which is exact for

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Err, I don't agree with the cast here. The stepper should compute with BigInts

};
}
case 'Pass':
return { type: 'ExpressionStatement', expression: identifier('pass') };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've not fully gone through the reduction steps, but is this a valid translation?
Even if it does symbolically work, it is a misnomer. Maybe introduce a PassStatement type which does nothing?

* renderer that dispatches on a node's `type` string and reads estree-style field names
* (`left`/`right`/`operator`, `test`/`consequent`/`alternate`, `callee`/`arguments`, ...). To reuse
* that renderer unchanged, the Python stepper does not operate on py-slang's class-based AST
* directly: it first {@link ../stepper/translate translates} the Python AST into these plain

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Minor) Why the ../stepper/translate instead of ./translate?

* Each {@link reduceProgram} call performs exactly one contraction and reports the redex/result so
* the driver can emit before/after markers. It returns `null` once the program is in normal form.
*
* Known limitations (acceptable for a teaching stepper): integer arithmetic is computed in JS

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These feel like pretty major limitations, hopefully they can be worked on

Comment on lines +114 to +123
} else if (typeof l === 'number' && typeof r === 'number') {
const pyFloat = Boolean(left.pyFloat) || Boolean(right.pyFloat) || op === '/';
switch (op) {
case '+': result = numberLiteral(l + r, pyFloat); break;
case '-': result = numberLiteral(l - r, pyFloat); break;
case '*': result = numberLiteral(l * r, pyFloat); break;
case '/': result = numberLiteral(l / r, true); break;
case '//': result = numberLiteral(Math.floor(l / r), pyFloat); break;
case '%': result = numberLiteral(((l % r) + r) % r, pyFloat); break;
case '**': result = numberLiteral(l ** r, pyFloat || l ** r !== Math.floor(l ** r)); break;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, please take a look at the existing operator combinations, Python has weird properties (e.g., the pyMod function)

It doesn't work exactly the same as JS

@Shrey5132

Copy link
Copy Markdown
Author

@AaravMalani Thanks for all the feedback
This is a WIP, I only opened the PR to let others see the work I've done so far.
I'm working on all the changes mentioned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants