#!/usr/bin/env python3 """Convert FRET JSON export to ltlsynt synthesis config. Automatically detects: - Mode variables: any variable matching `control_* = q_*` in the LTL. Each control group (e.g. control_mode) gets its own set of boolean outputs (e.g. in_mode_shutdown, in_mode_heatup) with a mutual exclusion constraint. - Environment inputs: variables that appear only in conditions (pre_condition / regular_condition) and are not mode variables. - System outputs: the boolean mode propositions derived from control_*. Naming convention in FRET: - Mode variables MUST start with "control_" (e.g. control_mode, control_pump) - Mode values MUST start with "q_" (e.g. q_shutdown, q_heatup) - All other variables are classified by their FRET role. Usage: python3 scripts/fret_to_synth.py [output.json] """ import json import re import sys from collections import defaultdict from pathlib import Path def discover_modes(requirements): """Scan all LTL formulas for (control_X = q_Y) patterns. Returns a dict: { "control_X": ["q_Y", "q_Z", ...] } Each key is a mode group, values are the discovered mode values. """ # Match patterns like (control_mode = q_shutdown), (control_pump = q_off) pattern = re.compile(r'\((\s*control_\w+)\s*=\s*(q_\w+)\s*\)') mode_groups = defaultdict(set) for req in requirements: sem = req["semantics"] for field in ("ftInfAUExpanded", "ft", "ftExpanded"): ltl = sem.get(field, "") if ltl: for match in pattern.finditer(ltl): ctrl_var = match.group(1).strip() mode_val = match.group(2).strip() mode_groups[ctrl_var].add(mode_val) # Sort mode values for deterministic output return {k: sorted(v) for k, v in sorted(mode_groups.items())} def build_mode_map(mode_groups): """Build the string replacement map and output variable list. For a single control group like control_mode with values [q_heatup, q_operation, q_scram, q_shutdown], produces: - replacements: {"(control_mode = q_heatup)": "in_mode_heatup", ...} - outputs: ["in_mode_heatup", "in_mode_operation", ...] For multiple groups, the boolean name includes the group suffix: control_mode -> in_mode_X control_pump -> in_pump_X """ replacements = {} outputs_by_group = {} for ctrl_var, mode_vals in mode_groups.items(): # Derive group suffix: control_mode -> mode, control_pump -> pump suffix = ctrl_var.replace("control_", "", 1) group_outputs = [] for qval in mode_vals: # q_shutdown -> shutdown val_name = qval.replace("q_", "", 1) bool_name = f"in_{suffix}_{val_name}" replacements[f"({ctrl_var} = {qval})"] = bool_name group_outputs.append(bool_name) outputs_by_group[ctrl_var] = group_outputs return replacements, outputs_by_group def classify_variables(requirements, mode_groups): """Classify non-mode variables as environment inputs. A variable is an environment input if: - It is NOT a control_* or q_* variable (those are handled by mode encoding) - It appears in any requirement's semantics.variables All mode propositions are system outputs (handled separately). """ # Collect all known mode-related variable names to exclude mode_var_names = set() for ctrl_var, mode_vals in mode_groups.items(): mode_var_names.add(ctrl_var) mode_var_names.update(mode_vals) # Collect all variables referenced across requirements all_vars = set() for req in requirements: sem = req["semantics"] for v in sem.get("variables", []): all_vars.add(v) # Everything that's not a mode variable is an environment input env_inputs = sorted(all_vars - mode_var_names) return env_inputs def encode_ltl(formula, replacements): """Replace all (control_X = q_Y) with boolean mode propositions.""" result = formula for pattern, replacement in replacements.items(): result = result.replace(pattern, replacement) # Sanity check: no control_ or q_ references should remain leftover = re.findall(r'control_\w+|q_\w+', result) if leftover: raise ValueError( f"Unhandled mode references in LTL: {leftover}\n" f" Formula: {result}\n" f" Known replacements: {list(replacements.keys())}" ) return result def mutual_exclusion_ltl(group_outputs): """Generate LTL for exactly-one-active constraint over a mode group. Produces: G( (a & !b & !c & !d) | (!a & b & !c & !d) | ... ) """ clauses = [] for i, m in enumerate(group_outputs): parts = [] for j, n in enumerate(group_outputs): if i == j: parts.append(n) else: parts.append(f"(! {n})") clauses.append(f"({' & '.join(parts)})") exactly_one = " | ".join(clauses) return f"G ({exactly_one})" def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} [output.json]", file=sys.stderr) sys.exit(1) input_path = Path(sys.argv[1]) output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("specs/synthesis_config_v3.json") with open(input_path) as f: fret_data = json.load(f) requirements = fret_data["requirements"] project = requirements[0]["project"] component = requirements[0]["semantics"]["component_name"] # --- Auto-discover everything --- mode_groups = discover_modes(requirements) replacements, outputs_by_group = build_mode_map(mode_groups) env_inputs = classify_variables(requirements, mode_groups) all_outputs = [out for group in outputs_by_group.values() for out in group] print(f"Discovered {len(mode_groups)} mode group(s):") for ctrl_var, modes in mode_groups.items(): print(f" {ctrl_var}: {modes}") print(f" -> booleans: {outputs_by_group[ctrl_var]}") print(f"Environment inputs: {env_inputs}") print(f"System outputs: {all_outputs}") print() # --- Encode requirements --- encoded_reqs = [] for req in requirements: reqid = req["reqid"] fulltext = req["fulltext"] sem = req["semantics"] # Prefer ftInfAUExpanded (cleanest infinite-horizon LTL) raw_ltl = sem.get("ftInfAUExpanded") or sem.get("ft") if not raw_ltl: print(f"WARNING: No LTL for {reqid}, skipping", file=sys.stderr) continue encoded = encode_ltl(raw_ltl, replacements) encoded_reqs.append({ "req_id": reqid, "fulltext": fulltext, "project": project, "component": component, "ltl": encoded, "ltl_original": raw_ltl, "condition_type": sem.get("condition", "unknown"), }) # --- Build structural constraints (one mutex per mode group) --- structural = [] for ctrl_var, group_outs in outputs_by_group.items(): mutex = mutual_exclusion_ltl(group_outs) structural.append({ "name": f"mutex_{ctrl_var}", "description": f"Exactly one {ctrl_var} value active at all times", "ltl": mutex, }) # --- Conjoin all formulas --- all_formulas = [r["ltl"] for r in encoded_reqs] all_formulas += [s["ltl"] for s in structural] conjoined = " & ".join(f"({f})" for f in all_formulas) # --- Write config --- config = { "_comment": ( f"Generated from {input_path.name} by fret_to_synth.py. " f"Boolean mode encoding with auto-discovered mode groups. " f"Mutual exclusion constraints are synthesis artifacts — they enforce " f"the single-valued semantics of control_* variables after decomposition " f"into independent booleans. They are NOT needed in FRET itself, where " f"control_mode = q_X is inherently single-valued." ), "spec_name": f"{project}_{component}", "source_file": str(input_path), "mode_groups": { ctrl_var: { "values": modes, "booleans": outputs_by_group[ctrl_var], } for ctrl_var, modes in mode_groups.items() }, "inputs": env_inputs, "outputs": all_outputs, "requirements": encoded_reqs, "structural_constraints": structural, "conjoined_ltl": conjoined, } output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w") as f: json.dump(config, f, indent=2) print(f"Synthesis config written to: {output_path}") print(f" Requirements: {len(encoded_reqs)}") print(f" Structural constraints: {len(structural)}") print(f" Conjoined formula length: {len(conjoined)} chars") print("\nEncoded requirements:") for r in encoded_reqs: print(f" {r['req_id']} [{r['condition_type']}]:") print(f" {r['ltl'][:120]}{'...' if len(r['ltl']) > 120 else ''}") if __name__ == "__main__": main()