Skip to content

Commit 4cc9cd4

Browse files
authored
Canonicalize generated trace literal order (#146)
1 parent 691d2c0 commit 4cc9cd4

5 files changed

Lines changed: 93 additions & 19 deletions

File tree

src/app.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -588,20 +588,22 @@ def authorquestion():
588588

589589
distractors = mergedDistractors
590590
new_distractors = []
591-
added_traces = set([answer])
592-
## IF the kind is trace satisfaction_mc, we need to generate traces for each distractor:
593-
if kind == "tracesatisfaction_mc" or kind == "tracesatisfaction_yn":
594-
# This is only true for trace_satisaction questions
595-
answer_formula = question
596-
597-
for distractor in distractors:
598-
f = distractor['formula']
599-
potential_trace_choices = spotutils.generate_traces(f_accepted=f, f_rejected=answer_formula, max_traces=10)
600-
trace_choices = [t for t in potential_trace_choices if t not in added_traces]
601-
if len(trace_choices) > 0:
602-
c = spotutils.weighted_trace_choice(trace_choices)
603-
ms = distractor['code']
604-
new_distractors.append({
591+
added_traces = set([exerciseprocessor.canonicalizeSpotTrace(answer)])
592+
## IF the kind is trace satisfaction_mc, we need to generate traces for each distractor:
593+
if kind == "tracesatisfaction_mc" or kind == "tracesatisfaction_yn":
594+
# This is only true for trace_satisaction questions
595+
answer_formula = question
596+
597+
for distractor in distractors:
598+
f = distractor['formula']
599+
potential_trace_choices = spotutils.generate_traces(f_accepted=f, f_rejected=answer_formula, max_traces=10)
600+
potential_trace_choices = [exerciseprocessor.canonicalizeSpotTrace(t) for t in potential_trace_choices]
601+
potential_trace_choices = list(dict.fromkeys(potential_trace_choices))
602+
trace_choices = [t for t in potential_trace_choices if t not in added_traces]
603+
if len(trace_choices) > 0:
604+
c = spotutils.weighted_trace_choice(trace_choices)
605+
ms = distractor['code']
606+
new_distractors.append({
605607
'formula': c,
606608
'code': ms
607609
})

src/authroutes.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,9 @@ def suggest_traces():
721721
# Generate satisfying traces
722722
sat_traces = spotutils.generate_accepted_traces(formula_str, max_traces=5)
723723
for trace in sat_traces:
724-
trace_str = str(trace)
724+
trace_str = exerciseprocessor.canonicalizeSpotTrace(str(trace))
725725
expanded = exerciseprocessor.expandSpotTrace(trace_str, literals)
726-
mermaid = exerciseprocessor.genMermaidGraphFromSpotTrace(trace_str)
726+
mermaid = exerciseprocessor.genMermaidGraphFromSpotTrace(expanded)
727727
satisfying_traces.append({
728728
'trace': expanded,
729729
'raw': trace_str,
@@ -735,9 +735,9 @@ def suggest_traces():
735735
negated = f"!({formula_str})"
736736
rej_traces = spotutils.generate_accepted_traces(negated, max_traces=5)
737737
for trace in rej_traces:
738-
trace_str = str(trace)
738+
trace_str = exerciseprocessor.canonicalizeSpotTrace(str(trace))
739739
expanded = exerciseprocessor.expandSpotTrace(trace_str, literals)
740-
mermaid = exerciseprocessor.genMermaidGraphFromSpotTrace(trace_str)
740+
mermaid = exerciseprocessor.genMermaidGraphFromSpotTrace(expanded)
741741
rejecting_traces.append({
742742
'trace': expanded,
743743
'raw': trace_str,

src/exercisebuilder.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,8 @@ def build_english_to_ltl_question(self, answer):
566566
}
567567

568568
def build_tracesat_mc_question(self, answer):
569+
import exerciseprocessor
570+
569571
options = self.get_options_with_misconceptions_as_formula(answer)
570572
if options is None:
571573
return None
@@ -589,6 +591,8 @@ def build_tracesat_mc_question(self, answer):
589591
potential_trace_choices = spotutils.generate_accepted_traces(formula, max_traces=max_choice_size)
590592
else:
591593
potential_trace_choices = spotutils.generate_traces(f_accepted=formula, f_rejected=parenthesized_answer, max_traces=max_choice_size)
594+
potential_trace_choices = [exerciseprocessor.canonicalizeSpotTrace(t) for t in potential_trace_choices]
595+
potential_trace_choices = list(dict.fromkeys(potential_trace_choices))
592596
existing_trace_options = [option['option'] for option in trace_options]
593597
trace_choices = [t for t in potential_trace_choices if t not in existing_trace_options]
594598
attempt_number += 1
@@ -617,6 +621,8 @@ def build_tracesat_mc_question(self, answer):
617621
}
618622

619623
def build_tracesat_yn_question(self, answer):
624+
import exerciseprocessor
625+
620626
formulae = self.get_options_with_misconceptions_as_formula(answer)
621627
parenthesized_answer = self.toSpotSyntax(answer)
622628

@@ -650,6 +656,9 @@ def build_tracesat_yn_question(self, answer):
650656

651657
feedbackString = f"The trace is accepted by the formula <code>{option_in_correct_syntax}</code>, but not by the formula <code>{correct_option_in_correct_syntax}</code>."
652658
misconceptions = formula['misconceptions']
659+
660+
potential_trace_choices = [exerciseprocessor.canonicalizeSpotTrace(t) for t in potential_trace_choices]
661+
potential_trace_choices = list(dict.fromkeys(potential_trace_choices))
653662

654663
if len(potential_trace_choices) == 0:
655664
return None

src/exerciseprocessor.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def _canonicalize_state(state_str: str) -> str:
9191
return cleaned
9292

9393
tokens = [t.strip() for t in re.split(rf'\s*{re.escape(NodeRepr.VAR_SEPARATOR)}\s*', cleaned) if t.strip() != ""]
94-
normalized = [t.replace('(', '').replace(')', '') for t in tokens]
94+
normalized = [t.replace('(', '').replace(')', '').strip() for t in tokens]
9595

9696
def sort_key(token: str):
9797
is_negated = token.startswith('!')
@@ -198,6 +198,36 @@ def nodeReprListsToSpotTrace(prefix_states, cycle_states) -> str:
198198
return prefix_string + ";" + cycle_string
199199

200200

201+
def canonicalizeSpotTrace(sr: str) -> str:
202+
"""
203+
Return a trace string with each state's literals in canonical order.
204+
This preserves the original prefix/cycle structure and only normalizes
205+
literal ordering within states.
206+
"""
207+
sr = sr.strip()
208+
if sr == "":
209+
return ""
210+
211+
prefix_split = sr.split('cycle', 1)
212+
prefix_parts = [x for x in prefix_split[0].strip().split(';') if x.strip() != ""]
213+
canonical_prefix = [NodeRepr._canonicalize_state(part) for part in prefix_parts]
214+
215+
cycle_parts = []
216+
if len(prefix_split) > 1:
217+
cycled_content = getCycleContent(prefix_split[1])
218+
cycle_parts = [x for x in cycled_content.split(';') if x.strip() != ""]
219+
cycle_parts = [NodeRepr._canonicalize_state(part) for part in cycle_parts]
220+
221+
prefix_string = ';'.join(canonical_prefix)
222+
if len(cycle_parts) > 0:
223+
cycle_string = "cycle{" + ';'.join(cycle_parts) + "}"
224+
if prefix_string == "":
225+
return cycle_string
226+
return prefix_string + ";" + cycle_string
227+
228+
return prefix_string
229+
230+
201231

202232
def expandSpotTrace(sr, literals) -> str:
203233

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import unittest
2+
import sys
3+
import os
4+
5+
# Add the src directory to sys.path
6+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
7+
8+
# Mock spot module to avoid import error in transitive imports
9+
from unittest.mock import MagicMock
10+
sys.modules['spot'] = MagicMock()
11+
sys.modules['inflect'] = MagicMock()
12+
sys.modules['wordfreq'] = MagicMock(zipf_frequency=lambda *args, **kwargs: 0)
13+
14+
from exerciseprocessor import canonicalizeSpotTrace
15+
16+
17+
class TestTraceCanonicalization(unittest.TestCase):
18+
def test_orders_literals_lexicographically_per_state(self):
19+
trace = "b & !a; c & a; cycle{!c & b; a & !b}"
20+
expected = "!a & b;a & c;cycle{b & !c;a & !b}"
21+
self.assertEqual(canonicalizeSpotTrace(trace), expected)
22+
23+
def test_handles_parentheses_and_whitespace(self):
24+
trace = " ( b ) & ( !a ) ; cycle{ (d) & c } "
25+
expected = "!a & b;cycle{c & d}"
26+
self.assertEqual(canonicalizeSpotTrace(trace), expected)
27+
28+
def test_empty_trace(self):
29+
self.assertEqual(canonicalizeSpotTrace(" "), "")
30+
31+
32+
if __name__ == "__main__":
33+
unittest.main()

0 commit comments

Comments
 (0)