From 99391d81ce1294f945f198f3a27f46a2dd094d38 Mon Sep 17 00:00:00 2001 From: Martin Legrand Date: Tue, 9 Jun 2026 19:04:44 +0200 Subject: [PATCH 01/71] feat: extend deps for verifier runner --- sources/core/evaluators/verifier.py | 2 +- sources/core/evaluators/verifier_per_claim.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sources/core/evaluators/verifier.py b/sources/core/evaluators/verifier.py index 1ce5129..e3e0f05 100644 --- a/sources/core/evaluators/verifier.py +++ b/sources/core/evaluators/verifier.py @@ -78,7 +78,7 @@ class VerifierEvaluator( _DEFAULT_CLAIM_IMPORTANCE = 5 _GRADIENT_MIN_IMPORTANCE = 3 _INFO_BONUS_MIN_IMPORTANCE = 6 - _HARD_FAIL_IMPORTANCE = 8 + _HARD_FAIL_IMPORTANCE = 10 _LEGACY_CRITICALITY_TO_IMPORTANCE = {"hard": 8, "soft": 3} def __init__( diff --git a/sources/core/evaluators/verifier_per_claim.py b/sources/core/evaluators/verifier_per_claim.py index 79c121b..e1ea05e 100644 --- a/sources/core/evaluators/verifier_per_claim.py +++ b/sources/core/evaluators/verifier_per_claim.py @@ -47,7 +47,8 @@ "pydantic", "pandera", "jsonschema", - "sympy" + "sympy", + "openpyxl" ) # Python module names corresponding to ``_VERIFIER_BASE_PACKAGES`` From da79379c8bd9826e4c1ee81ce0de3bf3317c98d8 Mon Sep 17 00:00:00 2001 From: martin Date: Wed, 10 Jun 2026 09:45:24 +0200 Subject: [PATCH 02/71] udpate --- config.py | 4 +- ...e_selection_logistic_regression_dkpes.json | 34 + run_notes/capsule_2704973191892021053.json | 34 + .../clintox_multitask_toxicity_approval.json | 61 + run_notes/clintox_multitask_toxicity_fda.json | 40 + ...dkpes_rf_signal_inhibition_prediction.json | 34 + ...isualization_habitat_spatial_clusters.json | 34 + ...ralai_mistral-large-2407_single_agent.json | 44 + run_notes/factors_tasks_correlations.json | 34 + ...unctional_groups_dkpes_top10_bottom10.json | 34 + run_notes/predict_bulk_modulus_rf.json | 34 + sources/cache/openrouter_pricing.json | 1369 ----------------- sources/evaluation/csv_mode.py | 2 +- workflow_evolution_anim.py | 41 +- 14 files changed, 422 insertions(+), 1377 deletions(-) create mode 100644 run_notes/backward_feature_selection_logistic_regression_dkpes.json create mode 100644 run_notes/capsule_2704973191892021053.json create mode 100644 run_notes/clintox_multitask_toxicity_approval.json create mode 100644 run_notes/clintox_multitask_toxicity_fda.json create mode 100644 run_notes/dkpes_rf_signal_inhibition_prediction.json create mode 100644 run_notes/elk_analysis_visualization_habitat_spatial_clusters.json create mode 100644 run_notes/evaluations/20260608_101543_run1_openrouter_mistralai_mistral-large-2407_single_agent.json create mode 100644 run_notes/factors_tasks_correlations.json create mode 100644 run_notes/functional_groups_dkpes_top10_bottom10.json create mode 100644 run_notes/predict_bulk_modulus_rf.json delete mode 100644 sources/cache/openrouter_pricing.json diff --git a/config.py b/config.py index 63258da..31a74df 100644 --- a/config.py +++ b/config.py @@ -48,8 +48,8 @@ def __init__(self): # LLMs choices self.planner_llm_model: str = "openrouter/z-ai/glm-5.1" self.workflow_llm_model: str = "openrouter/z-ai/glm-5.1" - self.smolagent_model_id: str = "openrouter/deepseek/deepseek-v3.2" - self.judge_model = "openrouter/minimax/minimax-m3" + self.smolagent_model_id: str = "openrouter/mistralai/mistral-large-2407" + self.judge_model = "openrouter/qwen/qwen3.7-max" self.capsule_namer_model = "openrouter/deepseek/deepseek-v4-flash" self.engine_name: str = "litellm" # for smolagent diff --git a/run_notes/backward_feature_selection_logistic_regression_dkpes.json b/run_notes/backward_feature_selection_logistic_regression_dkpes.json new file mode 100644 index 0000000..a1edceb --- /dev/null +++ b/run_notes/backward_feature_selection_logistic_regression_dkpes.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-08T11:18:35.243036", + "model": "openrouter/mistralai/mistral-large-2407", + "goal": "\nDOMAIN KNOWLEDGE:\nBackward feature selection is a feature selection technique where all available features are initially included in the fitted model. Then, features are sequentially removed based on their significance, with the least predictive feature being removed and a new model being fitted with the remaining features at each step. This process continues until the model's performance starts to decline or until some termination condition is met. To perform the backward feature selection, one can use SFS function from `mlxtend` library with appropriate arguments (e.g., set 'forward' to False).\n\nINSTRUCTIONS:\nPerform backward feature selection using logistic regression to identify the most relevant chemical features for predicting signal inhibition from the DKPES dataset. Binarize the signal inhibition values using appropriate threshold. Visualize the accuracy as a function of the number of selected features. Save the plot as \"pred_results/dkpes_feature_selection_analysis_pred.png\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_feature_selection_analysis_pred.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_visualization_3.py\n", + "execution_time_seconds": 503.6962056159973, + "analysis": "1. SUCCESS_LEVEL: Failed\n\n2. COMMENTS: \n- **What the workflow tried to do:** The single agent attempted to execute backward feature selection using logistic regression on the DKPES dataset, binarize the target variable (signal inhibition), and generate a visualization of accuracy as a function of the number of selected features.\n- **What worked:** The agent executed its internal logic and returned a \"SUCCESS\" status, claiming that the backward feature selection was completed and the results were saved.\n- **What failed and why:** Despite the agent's claim of success, the \"FILES USED, GENERATED OR MODIFIED\" section is completely empty. This is a critical failure, as it proves that neither the required plot (`pred_results/dkpes_feature_selection_analysis_pred.png`) nor the final Python script (`dkpes_visualization_3.py`) was actually generated or saved to the workspace. The agent either hallucinated the success message or the underlying code failed silently (e.g., due to missing directories, incorrect relative paths, or unhandled exceptions during the `mlxtend` SFS execution) without raising an error that the agent could catch.\n- **Areas for improvement:** The agent must never assume a task is complete just because the code ran without throwing a visible exception. It needs to implement strict post-execution validation, such as using `os.path.exists()` to verify that the exact expected files have been written to the correct paths before reporting success. Additionally, the agent should explicitly create necessary directories (like `pred_results/`) and implement robust error handling to capture and report silent failures during data processing, model fitting, and file I/O operations. An incomplete delivery of physical files renders the entire execution useless.", + "total_eval": 7, + "git": { + "commit": "9bc2185fcc9fade238d6c6e3c8619bb2b8bd28d0", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "backward_feature_selection_logistic_regression_dkpes", + "ver_success": 4, + "sr_success": 1, + "avg_cbs": 0.9101360835250049, + "total_cost": 4.840256, + "is_success": false, + "task_cost": 1.847152, + "max_judge_reward": 0.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "single_agent_20260608_110703_34a13434" + ], + "evolution_rewards": [ + 0.0 + ], + "evolution_costs": [ + 1.847152 + ], + "evolution_total_cost": 1.847152, + "evolution_avg_reward": 0.0, + "evolution_avg_cost": 1.847152 +} \ No newline at end of file diff --git a/run_notes/capsule_2704973191892021053.json b/run_notes/capsule_2704973191892021053.json new file mode 100644 index 0000000..d418d5e --- /dev/null +++ b/run_notes/capsule_2704973191892021053.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-08T11:01:27.713920", + "model": "openrouter/mistralai/mistral-large-2407", + "goal": "\nDOMAIN KNOWLEDGE:\nThe TanimotoCombo column presents the sum of the volumetric and chemical similarity components, where an exact match (two identical molecules in the same conformation) will result in a maximum score of 1 for each, summing to a maximum score of 2.\n\nINSTRUCTIONS:\nGiven the DKPES dataset, visualize the distribution of signal inhibition values and also visualize their relationship with the tanimoto similarity score. Save the figure as \"pred_results/dkpes_molecular_analysis_pred.png\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_molecular_analysis_pred.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_visualization_1.py\n", + "execution_time_seconds": 111.63464617729187, + "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \nThe single-agent workflow attempted to fulfill the data visualization task by generating a Python script to plot the distribution of 'Signal-inhibition' values and their relationship with the 'TanimotoCombo' score from the DKPES dataset. \n\n**What worked:** \nThe agent successfully interpreted the instructions, identified the correct columns from the dataset preview, and reportedly executed the code without any errors. It confirmed the creation of both the visualization image (`pred_results/dkpes_molecular_analysis_pred.png`) and the Python script (`dkpes_visualization_1.py`) at the exact requested paths.\n\n**What failed / Weaknesses:** \nWhile the agent self-reported a successful execution, the \"FILES USED, GENERATED OR MODIFIED\" section in the execution trace is completely empty. This is a significant logging failure, as it prevents external verification of whether the files were actually written to the correct directories or if the agent merely hallucinated its success. In a strict evaluation, relying solely on an agent's self-reported outcome without file system confirmation is a vulnerability. \n\n**Areas for improvement:** \nThe execution framework must ensure that file generation and modification events are properly captured and logged in the execution trace. Additionally, the agent's final answer is overly brief; it should ideally provide a summary of the visual insights (e.g., the shape of the distribution, the correlation observed between signal inhibition and Tanimoto similarity) to demonstrate that the analysis was actually performed and understood, rather than just confirming file creation. Assuming the files were indeed created as claimed, the goal was achieved.", + "total_eval": 5, + "git": { + "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "capsule_2704973191892021053", + "ver_success": 3, + "sr_success": 1, + "avg_cbs": 0.9163455709514656, + "total_cost": 2.87623, + "is_success": false, + "task_cost": 0.114444, + "max_judge_reward": 0.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "single_agent_20260608_105617_dbcffb2a" + ], + "evolution_rewards": [ + 0.0 + ], + "evolution_costs": [ + 0.114444 + ], + "evolution_total_cost": 0.114444, + "evolution_avg_reward": 0.0, + "evolution_avg_cost": 0.114444 +} \ No newline at end of file diff --git a/run_notes/clintox_multitask_toxicity_approval.json b/run_notes/clintox_multitask_toxicity_approval.json new file mode 100644 index 0000000..6a687b6 --- /dev/null +++ b/run_notes/clintox_multitask_toxicity_approval.json @@ -0,0 +1,61 @@ +{ + "timestamp": "2026-06-07T16:00:02.194167", + "model": "openrouter/deepseek/deepseek-v4-flash", + "goal": "\nDOMAIN KNOWLEDGE:\n1. *On the task*: The ClinTox dataset contains drugs approved by the US Food and Drug Administration (FDA) and drugs that have failed clinical trials for toxicity reasons. The dataset includes two binary classification tasks for 1491 drug compounds with known chemical structures: (1) clinical trial toxicity (or absence of toxicity) and (2) FDA approval status. This requires developing a model with two binary classification heads each determining the label of its respective task based on the molecular structure. Use `MultitaskClassifier` model from the deepchem library.\n2. *On featurization*: To represent the molecular structure, use Extended-Connectivity Fingerprints (ECFPs) featurization in deepchem. ECFPs are circular topological fingerprints that represent the presence of particular substructures and stereochemical information.\n\nINSTRUCTIONS:\nTrain a multitask model on the Clintox dataset to predict a drug's toxicity and FDA approval status. Save the test set predictions, including the SMILES representation of drugs and the probability of positive labels, to \"pred_results/clintox_test_pred.csv\".\n\nDATASET STRUCTURE:\n|-- clintox/\n|---- clintox_test.csv\n|---- clintox_train.csv\n\nDATASET PREVIEW:\n[START Preview of clintox/clintox_train.csv]\nsmiles,FDA_APPROVED,CT_TOX\nCCC(/C=C/Cl)(C#C)O,1,0\nC[C@H]1C[C@H]2[C@@H]3CC[C@@H]([C@]3(C[C@@H]([C@@H]2[C@@]4(C1=CC(=O)CC4)C)O)C)C(=O)C,1,0\nC[C@@H]1CCN([C@H](C1)C(=O)[O-])C(=O)[C@H](CCC[NH+]=C(N)N)NS(=O)(=O)c2cccc3c2NC[C@@H](C3)C,1,0\n...\n[END Preview of clintox/clintox_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/clintox_test_pred.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: clintox_nn.py\n", + "execution_time_seconds": 6826.115881443024, + "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \n- **What the workflow tried to do**: The multi-agent system aimed to train a DeepChem `MultitaskClassifier` using Extended-Connectivity Fingerprints (ECFPs) on the ClinTox dataset. The goal was to predict clinical trial toxicity and FDA approval status, and save the test set predictions (SMILES and positive-class probabilities) to a specific CSV path, while keeping a single Python script.\n- **What worked**: The agents demonstrated excellent problem-solving skills by identifying and resolving a critical environment incompatibility between DeepChem 2.5.0 and TensorFlow 2.16.1 via monkey-patching. The model was successfully trained, and the validator confirmed that the output CSV contains actual probabilities (evidenced by the high number of unique values in the prediction columns) rather than just binary labels. All required artifacts (`clintox_nn.py` and `pred_results/clintox_test_pred.csv`) were generated and saved in the exact requested directories.\n- **What failed or needs improvement**: There is a minor data integrity flaw: the prediction file contains 292 rows for 291 unique test SMILES, meaning one drug's prediction was duplicated. Additionally, the builder reported a `NaN` ROC-AUC score, assuming the test set had \"all same labels,\" which is highly unlikely for the standard ClinTox test split and suggests a potential bug in how the true labels were handled during evaluation (though calculating ROC-AUC was not strictly required by the prompt). Finally, the execution time was exceptionally long (~113 minutes), which could be optimized by reducing epoch counts or streamlining the environment setup.\n- **Overall**: The primary objectives were fully and successfully met. The minor anomalies in row duplication and evaluation metrics do not invalidate the core deliverables, making this a highly successful execution.", + "total_eval": 1, + "git": { + "commit": "01e640fe023e47e7d1f047d0eec9795e11de96a1", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "clintox_multitask_toxicity_approval", + "ver_success": 0, + "sr_success": 0, + "avg_cbs": 0.8916471626811059, + "total_cost": 3.8073243219, + "is_success": false, + "task_cost": 3.8073243219, + "max_judge_reward": 0.9567, + "evolution_iterations": 10, + "evolved_workflows_uuids": [ + "20260607_140047_ea386abb", + "20260607_141634_fdc8df1c", + "20260607_143732_49c1f07d", + "20260607_144649_2403f41f", + "generation_failed", + "20260607_145952_22b94e73", + "20260607_151436_88454304", + "generation_failed", + "20260607_152835_c7101065", + "20260607_154504_9751225a" + ], + "evolution_rewards": [ + 0.9108, + 0.7313, + 0.8614, + 0.7142, + 0.0, + 0.7459, + 0.2941, + 0.0, + 0.0, + 0.9567 + ], + "evolution_costs": [ + 1.0641413546999998, + 1.0532012297, + 0.20088649629999988, + 0.3192504145999999, + 0.005209605100000125, + 0.6119852561000001, + 0.3540861274, + 0.005209605100000125, + 0.029022980000000143, + 0.16433125289999984 + ], + "evolution_total_cost": 3.8073243219, + "evolution_avg_reward": 0.52144, + "evolution_avg_cost": 0.38073243218999997 +} \ No newline at end of file diff --git a/run_notes/clintox_multitask_toxicity_fda.json b/run_notes/clintox_multitask_toxicity_fda.json new file mode 100644 index 0000000..b625847 --- /dev/null +++ b/run_notes/clintox_multitask_toxicity_fda.json @@ -0,0 +1,40 @@ +{ + "timestamp": "2026-06-08T13:03:50.750624", + "model": "openrouter/deepseek/deepseek-v4-flash", + "goal": "\nDOMAIN KNOWLEDGE:\n1. *On the task*: The ClinTox dataset contains drugs approved by the US Food and Drug Administration (FDA) and drugs that have failed clinical trials for toxicity reasons. The dataset includes two binary classification tasks for 1491 drug compounds with known chemical structures: (1) clinical trial toxicity (or absence of toxicity) and (2) FDA approval status. This requires developing a model with two binary classification heads each determining the label of its respective task based on the molecular structure. Use `MultitaskClassifier` model from the deepchem library.\n2. *On featurization*: To represent the molecular structure, use Extended-Connectivity Fingerprints (ECFPs) featurization in deepchem. ECFPs are circular topological fingerprints that represent the presence of particular substructures and stereochemical information.\n\nINSTRUCTIONS:\nTrain a multitask model on the Clintox dataset to predict a drug's toxicity and FDA approval status. Save the test set predictions, including the SMILES representation of drugs and the probability of positive labels, to \"pred_results/clintox_test_pred.csv\".\n\nDATASET STRUCTURE:\n|-- clintox/\n|---- clintox_test.csv\n|---- clintox_train.csv\n\nDATASET PREVIEW:\n[START Preview of clintox/clintox_train.csv]\nsmiles,FDA_APPROVED,CT_TOX\nCCC(/C=C/Cl)(C#C)O,1,0\nC[C@H]1C[C@H]2[C@@H]3CC[C@@H]([C@]3(C[C@@H]([C@@H]2[C@@]4(C1=CC(=O)CC4)C)O)C)C(=O)C,1,0\nC[C@@H]1CCN([C@H](C1)C(=O)[O-])C(=O)[C@H](CCC[NH+]=C(N)N)NS(=O)(=O)c2cccc3c2NC[C@@H](C3)C,1,0\n...\n[END Preview of clintox/clintox_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/clintox_test_pred.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: clintox_nn.py\n", + "execution_time_seconds": 2428.3582360744476, + "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \nThe multi-agent workflow successfully completed the assigned task, demonstrating strong problem-solving and debugging capabilities. \n\n**What worked:**\n- **Task Completion:** The builder agent successfully trained a `MultitaskClassifier` using ECFP featurization on the ClinTox dataset and saved the test predictions to the exact required path (`pred_results/clintox_test_pred.csv`). The final script was correctly saved as `clintox_nn.py`.\n- **Autonomous Debugging:** The builder encountered multiple environment and library issues, including missing packages (numpy, tensorflow, rdkit), array shape errors during featurization, and DeepChem/TensorFlow version incompatibilities. It autonomously resolved all of these by installing dependencies, rewriting the featurization process to handle SMILES individually with a zero-vector fallback, and applying a `tf-keras` compatibility patch.\n- **Validation:** The grounded validator thoroughly verified the outputs, confirming the correct row count (292), valid probability ranges for the predictions, and the mathematical correctness of the undefined ROC-AUC scores (due to the test set containing only the positive class).\n\n**What failed / Areas for improvement:**\n- **Execution Time:** The execution took roughly 40 minutes (2428 seconds), largely due to the iterative trial-and-error process of resolving dependency and compatibility errors. Pre-configuring the environment with the correct `deepchem`, `tensorflow`, and `rdkit` versions would significantly speed up future runs.\n- **Output Clarity:** While the output CSV correctly contains the probabilities of the positive labels as requested, the column names (`FDA_APPROVED`, `CT_TOX`) are identical to the true label columns. Renaming them to `FDA_APPROVED_prob` and `CT_TOX_prob` would have prevented any ambiguity between true labels and predicted probabilities.\n\n**Scalability:**\nThe approach is highly scalable and robust. The implementation of individual SMILES processing with fallback mechanisms and the proactive handling of TensorFlow/Keras compatibility layers ensures that the script can reliably process similar chemical datasets without crashing on invalid molecules or library version mismatches.", + "total_eval": 4, + "git": { + "commit": "6a18b3ebc443f65d4fc157e251f9fab43ce27038", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "clintox_multitask_toxicity_fda", + "ver_success": 3, + "sr_success": 2, + "avg_cbs": 0.9354476347373322, + "total_cost": 10.470079470499998, + "is_success": true, + "task_cost": 5.460983531299999, + "max_judge_reward": 0.9408, + "evolution_iterations": 3, + "evolved_workflows_uuids": [ + "20260608_121947_624cad65", + "generation_failed", + "20260608_124321_28dbc7cc" + ], + "evolution_rewards": [ + 0.81, + 0.0, + 0.9408 + ], + "evolution_costs": [ + 5.022916422299999, + 0.005209605100000125, + 0.4328575039000002 + ], + "evolution_total_cost": 5.460983531299999, + "evolution_avg_reward": 0.5836, + "evolution_avg_cost": 1.8203278437666663 +} \ No newline at end of file diff --git a/run_notes/dkpes_rf_signal_inhibition_prediction.json b/run_notes/dkpes_rf_signal_inhibition_prediction.json new file mode 100644 index 0000000..e429b0f --- /dev/null +++ b/run_notes/dkpes_rf_signal_inhibition_prediction.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-08T10:56:16.329784", + "model": "openrouter/mistralai/mistral-large-2407", + "goal": "\nDOMAIN KNOWLEDGE:\nNone\n\nINSTRUCTIONS:\nUse the DKPES dataset to develop a Random Forest classifier predicting signal inhibition of chemicals while choosing appropriate threshold to assign binary labels based on signal inhibition values. Save the test set predictions, including the index and predicted signal inhibition, in \"pred_results/dkpes_test_pred.csv\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_test_pred.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_model_development_1.py\n", + "execution_time_seconds": 315.0821957588196, + "analysis": "1. SUCCESS_LEVEL: Failed\n\n2. COMMENTS: The multi-agent workflow (consisting of a single agent) completely failed to achieve the objective. The agent attempted to train a Random Forest classifier on the DKPES dataset but encountered a fundamental data preprocessing error. The dataset includes a categorical/string column named 'ShapeQuery' (e.g., 'DKPES_CSD_MMMF_1_32'), which cannot be processed by Scikit-Learn's Random Forest implementation without prior encoding or exclusion. \n\nWhile the agent correctly identified the source of the `ValueError: could not convert string to float`, it demonstrated poor problem-solving capabilities by failing to successfully implement a fix (such as simply dropping the column or applying label encoding) despite multiple attempts. As a result of this basic preprocessing failure, the model was never trained, no threshold was determined, and neither the required prediction CSV file (`pred_results/dkpes_test_pred.csv`) nor the final Python script (`dkpes_model_development_1.py`) were generated. The execution was a complete failure due to the agent's inability to handle standard tabular data cleaning tasks.", + "total_eval": 4, + "git": { + "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "dkpes_rf_signal_inhibition_prediction", + "ver_success": 2, + "sr_success": 1, + "avg_cbs": 0.9229156532575468, + "total_cost": 2.761786, + "is_success": false, + "task_cost": 1.283458, + "max_judge_reward": 0.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "single_agent_20260608_104814_625b602d" + ], + "evolution_rewards": [ + 0.0 + ], + "evolution_costs": [ + 1.283458 + ], + "evolution_total_cost": 1.283458, + "evolution_avg_reward": 0.0, + "evolution_avg_cost": 1.283458 +} \ No newline at end of file diff --git a/run_notes/elk_analysis_visualization_habitat_spatial_clusters.json b/run_notes/elk_analysis_visualization_habitat_spatial_clusters.json new file mode 100644 index 0000000..fa0ba1b --- /dev/null +++ b/run_notes/elk_analysis_visualization_habitat_spatial_clusters.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-08T10:48:14.017179", + "model": "openrouter/mistralai/mistral-large-2407", + "goal": "\nDOMAIN KNOWLEDGE:\n\"Home range\" can be defined as the area within which an animal normally lives and finds what it needs for survival. Basically, the home range is the area that an animal travels for its normal daily activities. \"Minimum Bounding Geometry\" creates a feature class containing polygons which represent a specified minimum bounding geometry enclosing each input feature or each group of input features. \"Convex hull\" is the smallest convex polygon that can enclose a group of objects, such as a group of points.\n\nINSTRUCTIONS:\nAnalyze and visualize Elk movements in the given dataset. Estimate home ranges and assess habitat preferences using spatial analysis techniques. Identify the spatial clusters of Elk movements. Document the findings with maps and visualizations. Save the figure as \"pred_results/Elk_Analysis.png\".\n\nDATASET STRUCTURE:\n|-- ElkMovement/\n|---- Elk_in_Southwestern_Alberta_2009.geojson\n\nDATASET PREVIEW:\n[START Preview of ElkMovement/Elk_in_Southwestern_Alberta_2009.geojson]\n{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"id\":1,\"geometry\":{\"type\":\"Point\",\"coordinates\":[-114.19111179959417,49.536741600111178]},\"properties\":{\"OBJECTID\":1,\"timestamp\":\"2009-01-01 01:00:37\",\"long\":-114.1911118,\"lat\":49.536741599999999,\"comments\":\"Carbondale\",\"external_t\":-5,\"dop\":2.3999999999999999,\"fix_type_r\":\"3D\",\"satellite_\":0,\"height\":1375.1900000000001,\"crc_status\":\" \",\"outlier_ma\":0,\"sensor_typ\":\"gps\",\"individual\":\"Cervus elaphus\",\"tag_ident\":\"856\",\"ind_ident\":\"E001\",\"study_name\":\"Elk in southwestern Alberta\",\"date\":1709164800000,\"time\":\" \",\"timestamp_Converted\":1230771637000,\"summer_indicator\":1}},{\"type\":\"Feature\",\"id\":2,\"geometry\":{\"type\":\"Point\",\"coordinates\":[-114.1916239994119,49.536505999952517]},\"properties\":{\"OBJECTID\":2,\"timestamp\":\"2009-01-01 03:00:52\",\"long\":-114.191624,\"lat\":49.536506000000003,\"comments\":\"Carbondale\",\"external_t\":-6,\"dop\":2.3999999999999999,\"fix_type_r\":\"3D\",\"satellite_\":0,\"height\":1375.2,\"crc_status\":\" \",\"outlier_ma\":0,\"sensor_typ\":\"gps\",\"individual\":\"Cervus elaphus\",\"tag_ident\":\"856\",\"ind_ident\":\"E001\",\"study_name\":\"Elk in southwestern Alberta\",\"date\":1709164800000,\"time\":\" \",\"timestamp_Converted\":1230778852000,\"summer_indicator\":1}},{\"type\":\"Feature\",\"id\":3,\"geometry\":{\"type\":\"Point\",\"coordinates\":[-114.19169140075056,49.536571800069581]},\"properties\":{\"OBJECTID\":3,\"timestamp\":\"2009-01-01 05:00:49\",\"long\":-114.1916914,\"lat\":49.536571799999997,\"comments\":\"Carbondale\",\"external_t\":-6,\"dop\":5.6000000000000014,\"fix_type_r\":\"3D\",\"satellite_\":0,\"height\":1382.0999999999999,\"crc_status\":\" \",\"outlier_ma\":0,\"sensor_typ\":\"gps\",\"individual\":\"Cervus elaphus\",\"tag_ident\":\"856\",\"ind_ident\":\"E001\",\"study_name\":\"Elk in southwestern Alberta\",\"date\":1709164800000,\"time\":\" \",\"timestamp_Converted\":1230786049000,\"summer_indicator\":1}},...]}\n[END Preview of ElkMovement/Elk_in_Southwestern_Alberta_2009.geojson]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/Elk_Analysis.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: elk_new.py\n", + "execution_time_seconds": 185.73629331588745, + "analysis": "1. SUCCESS_LEVEL: Failed\n\n2. COMMENTS: \nThe single-agent workflow attempted to analyze elk movements, estimate home ranges, and visualize the spatial data. It successfully generated the required output files (`pred_results/Elk_Analysis.png` and `elk_new.py`) and applied a basic minimum bounding geometry (convex hull) to estimate home ranges. \n\nHowever, the execution failed to achieve the full goal due to significant omissions:\n- **Missing Analysis:** The agent completely ignored the instructions to \"assess habitat preferences\" and \"identify the spatial clusters of Elk movements.\" No clustering algorithms (e.g., DBSCAN, KDE) or habitat preference evaluations (e.g., analyzing the `height` attribute or land use) were performed.\n- **Incomplete Data Processing:** The agent arbitrarily restricted the analysis to only 3 out of 68 individuals in the dataset. While it noted this was a \"representative sample,\" an autonomous scientific workflow should process the entire dataset or a statistically robust sample unless constrained by computational limits. \n- **Basic Methodology:** The spatial analysis techniques were overly simplistic, relying solely on convex hulls without exploring more nuanced spatial patterns required by the prompt.\n\nTo improve, future executions must strictly parse and address every analytical requirement in the prompt, implement appropriate clustering and habitat assessment techniques, and process the full dataset rather than an arbitrarily small subset.", + "total_eval": 3, + "git": { + "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "elk_analysis_visualization_habitat_spatial_clusters", + "ver_success": 2, + "sr_success": 1, + "avg_cbs": 0.9338881262918104, + "total_cost": 1.478328, + "is_success": false, + "task_cost": 0.167122, + "max_judge_reward": 0.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "single_agent_20260608_104132_34852165" + ], + "evolution_rewards": [ + 0.0 + ], + "evolution_costs": [ + 0.167122 + ], + "evolution_total_cost": 0.167122, + "evolution_avg_reward": 0.0, + "evolution_avg_cost": 0.167122 +} \ No newline at end of file diff --git a/run_notes/evaluations/20260608_101543_run1_openrouter_mistralai_mistral-large-2407_single_agent.json b/run_notes/evaluations/20260608_101543_run1_openrouter_mistralai_mistral-large-2407_single_agent.json new file mode 100644 index 0000000..6cc330d --- /dev/null +++ b/run_notes/evaluations/20260608_101543_run1_openrouter_mistralai_mistral-large-2407_single_agent.json @@ -0,0 +1,44 @@ +{ + "started_at": "2026-06-08T10:15:43.649003", + "run_id": 1, + "smolagent_model_id": "openrouter/mistralai/mistral-large-2407", + "workflow_llm_model": "openrouter/z-ai/glm-5.1", + "eval_mode": "single_agent", + "csv_runs_limit": 10, + "discovery_addresses": [ + { + "ip": "0.0.0.0", + "port_min": 5100, + "port_max": 5200 + } + ], + "workspace_dir": "/home/martin/Projects/CNRS/Toolomics/workspace_2", + "detected_mcps": [ + "mcp_5100: 4 tools available | running on 0.0.0.0:5100", + "mcp_5101: 5 tools available | running on 0.0.0.0:5101", + "mcp_5102: 5 tools available | running on 0.0.0.0:5102", + "mcp_5103: 2 tools available | running on 0.0.0.0:5103", + "mcp_5104: 12 tools available | running on 0.0.0.0:5104", + "mcp_5105: 3 tools available | running on 0.0.0.0:5105", + "mcp_5106: 10 tools available | running on 0.0.0.0:5106", + "mcp_5107: 5 tools available | running on 0.0.0.0:5107", + "mcp_5108: 10 tools available | running on 0.0.0.0:5108", + "mcp_5109: 3 tools available | running on 0.0.0.0:5109", + "mcp_5110: 6 tools available | running on 0.0.0.0:5110" + ], + "dataset": "datasets/ScienceAgentBench.csv", + "status": "running", + "queue_size": 1, + "final_results": { + "steps_evaluated": 9, + "successful_runs": 8, + "success_rate": "88.9%", + "ver_success": 4, + "ver_total": 8, + "sr_success": 1, + "sr_total": 8, + "avg_cbs": 0.9056779066322573, + "total_cost": 4.997864 + }, + "finished_at": "2026-06-08T11:41:56.912824" +} diff --git a/run_notes/factors_tasks_correlations.json b/run_notes/factors_tasks_correlations.json new file mode 100644 index 0000000..71c230b --- /dev/null +++ b/run_notes/factors_tasks_correlations.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-08T11:41:56.912992", + "model": "openrouter/mistralai/mistral-large-2407", + "goal": "\nDOMAIN KNOWLEDGE:\nNone\n\nINSTRUCTIONS:\nCompute the correlations of FACTORS tasks and save the results. Load the FACTORS dataset, compute the Pearson correlation coefficient between each task and every other task. Save the computed results as a histogram to \"./pred_results/Factors_correlations.png\".\n\nDATASET STRUCTURE:\n|-- FACTORS/\n|---- FACTORS_training_disguised_combined_full.csv\n|---- FACTORS_test1_disguised_combined.csv\n|---- FACTORS_test2_disguised_combined.csv\n|---- scripts/\n|------ FACTORS_features.py\n|------ FACTORS_datasets.py\n\nDATASET PREVIEW:\n[START Preview of benchmark/datasets/FACTORS/FACTORS_training_distinguised_combined_full.csv]\nMolecule,D_00001,D_00002,D_00003,D_00004,D_00005, ...\nM_0164851,0,0,0,0,0, ...\nM_0164852,0,0,0,0,0, ...\n...\n[END Preview of benchmark/datasets/FACTORS/FACTORS_training_distinguised_combined_full.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/Factors_correlations.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: FACTORS_correlations.py\n", + "execution_time_seconds": 929.0803868770599, + "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: The workflow, executed by a single agent, successfully completed the assigned computational and visualization task. The agent correctly interpreted the instructions to load the FACTORS dataset, compute the Pearson correlation coefficients between all task pairs, and save the distribution of these correlations as a histogram. The expected output files (`pred_results/Factors_correlations.png` and `FACTORS_correlations.py`) were reportedly generated at the exact specified paths, and the execution finished well within the time limit (929 seconds) without encountering any errors.\n\nStrengths:\n- Precise adherence to instructions: The agent correctly identified the required statistical method (Pearson correlation) and the exact output formats and paths.\n- Efficiency: A single agent was sufficient to handle the data loading, computation, and plotting steps without unnecessary multi-agent overhead.\n\nWeaknesses & Areas for Improvement:\n- File Tracking Anomaly: A strict observation is that the \"FILES USED, GENERATED OR MODIFIED\" log in the execution trace is completely empty. While the agent reported a successful status and provided the correct file paths, the lack of file system tracking confirmation suggests a potential blind spot in the environment's logging mechanism, or the files were generated in an untracked virtual environment/memory rather than the persistent workspace. \n- Verification: In a strictly autonomous scientific pipeline, self-reported success without environmental file-tracking verification is a vulnerability. Future executions must ensure that file generation is properly captured and verified by the system's file tracker to guarantee verifiable reproducibility and prevent \"hallucinated\" file creations.", + "total_eval": 8, + "git": { + "commit": "9bc2185fcc9fade238d6c6e3c8619bb2b8bd28d0", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "factors_tasks_correlations", + "ver_success": 4, + "sr_success": 1, + "avg_cbs": 0.9056779066322573, + "total_cost": 4.997864, + "is_success": false, + "task_cost": 0.157608, + "max_judge_reward": 0.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "single_agent_20260608_111836_93bbaa32" + ], + "evolution_rewards": [ + 0.0 + ], + "evolution_costs": [ + 0.157608 + ], + "evolution_total_cost": 0.157608, + "evolution_avg_reward": 0.0, + "evolution_avg_cost": 0.157608 +} \ No newline at end of file diff --git a/run_notes/functional_groups_dkpes_top10_bottom10.json b/run_notes/functional_groups_dkpes_top10_bottom10.json new file mode 100644 index 0000000..1f5b372 --- /dev/null +++ b/run_notes/functional_groups_dkpes_top10_bottom10.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-08T11:07:02.757927", + "model": "openrouter/mistralai/mistral-large-2407", + "goal": "\nDOMAIN KNOWLEDGE:\n1. *On functional groups and activity*: In cheminformatics, functional groups like hydroxyl, keto, and amino substructures play a crucial role in determining molecular properties, including biological activity. The interaction between small molecules with receptors (e.g., proteins) or enzymes depends on the presence of these groups, affecting inhibitory activity. Therefore, visualizing the presence or absence of functional groups in the top and least active molecules can reveal structure-activity relationships (SAR).\n\n2. *On signal inhibition as a measure of activity*: Signal inhibition is a standard metric (e.g., IC50, Kd) used to quantify how effectively a molecule can suppress or inhibit biological signals. In this dataset, molecules with the highest and lowest values in the `Signal-inhibition` column represent the most and least active molecules, respectively. The next 11 columns represent the count of specific functional groups.\n\nINSTRUCTIONS:\nVisualize the distribution of functional groups for the 10 most and 10 least active molecules in the DKPES dataset. Save the figure as \"pred_results/dkpes_molecular_activity_analysis_pred.png\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_molecular_activity_analysis_pred.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_visualization_2.py\n", + "execution_time_seconds": 164.39737749099731, + "analysis": "1. SUCCESS_LEVEL: Medium\n\n2. COMMENTS: \nThe multi-agent workflow (consisting of a single agent) attempted to read the DKPES dataset, identify the 10 most and 10 least active molecules based on the `Signal-inhibition` column, and visualize the distribution of the 11 specified functional groups. The agent reported a successful execution, claiming that the visualization was saved to the exact required path (`pred_results/dkpes_molecular_activity_analysis_pred.png`) and the Python script was saved as `dkpes_visualization_2.py` without encountering any errors. \n\nHowever, the \"FILES USED, GENERATED OR MODIFIED\" section in the execution log is completely empty. This is a critical weakness, as it raises significant doubt about whether the files were actually written to the disk, saved in the correct directories, or if the environment simply failed to track the file I/O operations. Because there is no concrete evidence of file generation in the system logs, a full \"High\" success rating cannot be awarded. \n\nThe approach of using a single agent for this straightforward data manipulation and visualization task is logically sound, efficient, and highly scalable for similar cheminformatics exploratory data analysis (EDA) goals. For future executions, the system must ensure that file creation is strictly verified and properly logged to confirm task completion.", + "total_eval": 6, + "git": { + "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "functional_groups_dkpes_top10_bottom10", + "ver_success": 4, + "sr_success": 1, + "avg_cbs": 0.9161655723651498, + "total_cost": 2.993104, + "is_success": false, + "task_cost": 0.116874, + "max_judge_reward": 0.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "single_agent_20260608_110128_8f10756f" + ], + "evolution_rewards": [ + 0.0 + ], + "evolution_costs": [ + 0.116874 + ], + "evolution_total_cost": 0.116874, + "evolution_avg_reward": 0.0, + "evolution_avg_cost": 0.116874 +} \ No newline at end of file diff --git a/run_notes/predict_bulk_modulus_rf.json b/run_notes/predict_bulk_modulus_rf.json new file mode 100644 index 0000000..472ad5f --- /dev/null +++ b/run_notes/predict_bulk_modulus_rf.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-06-07T16:41:43.177987", + "model": "openrouter/deepseek/deepseek-v4-flash", + "goal": "\nDOMAIN KNOWLEDGE:\n*On bulk modulus*: The bulk modulus measures a material's resistance to uniform compression. It is a critical mechanical property in materials science, indicating how incompressible a material is.\n*On features*: Features can be generated from material compositions and crystal structures using tools like Matminer. Features include composition descriptors such as electronegativity, and crystal structure descriptors such as coordination number, oxidation state, density, etc.\n\nINSTRUCTIONS:\nTrain a random forest model with the given dataset of inorganic crystalline compounds to predict their bulk modulus (K_VRH). Format the test set predictions as a two column dataframe, material_id and K_VRH, and save it to \"pred_results/compound_bulk_modulus.csv\".\n\nDATASET STRUCTURE:\n|-- crystalline_compound/\n|---- compound_elastic_properties_train.csv\n|---- compound_elastic_properties_test.csv\n\nDATASET PREVIEW:\n[START Preview of crystalline_compound/compound_elastic_properties_train.csv]\nmaterial_id,formula,space_group,structure,elastic_anisotropy,G_VRH,K_VRH,poisson_ratio\nmp-2705,VPt3,139,\"Full Formula (V2 Pt6)\nReduced Formula: VPt3\nabc : 3.892168 3.892168 7.942608\nangles: 90.000000 90.000000 90.000000\npbc : True True True\nSites (8)\n # SP a b c\n--- ---- --- --- ----\n 0 V 0 0 0\n 1 V 0.5 0.5 0.5\n 2 Pt 0 0.5 0.25\n 3 Pt 0.5 0 0.25\n 4 Pt 0.5 0.5 0\n 5 Pt 0.5 0 0.75\n 6 Pt 0 0.5 0.75\n 7 Pt 0 0 0.5\",0.585191191098,110.729547405,240.74394255400003,0.300597811521\nmp-922,CoPt3,221,\"Full Formula (Co1 Pt3)\nReduced Formula: CoPt3\nabc : 3.890389 3.890389 3.890389\nangles: 90.000000 90.000000 90.000000\npbc : True True True\nSites (4)\n # SP a b c\n--- ---- --- --- ---\n 0 Co 0 0 0\n 1 Pt 0 0.5 0.5\n 2 Pt 0.5 0.5 0\n 3 Pt 0.5 0 0.5\",0.34914991459,91.3885939807,220.629335072,0.318017855437\nmp-30490,Mg3Cd,194,\"Full Formula (Mg6 Cd2)\nReduced Formula: Mg3Cd\nabc : 6.313065 6.313066 5.091341\nangles: 90.000000 90.000000 120.000002\npbc : True True True\nSites (8)\n # SP a b c\n--- ---- -------- -------- ----\n 0 Mg 0.664434 0.832217 0.25\n 1 Mg 0.832217 0.167783 0.75\n 2 Mg 0.335566 0.167783 0.75\n 3 Mg 0.167783 0.832217 0.25\n 4 Mg 0.167783 0.335566 0.25\n 5 Mg 0.832217 0.664434 0.75\n 6 Cd 0.666667 0.333333 0.25\n 7 Cd 0.333333 0.666667 0.75\",0.0520508538327,20.9005568647,39.8842394193,0.276946960007\n...\n[END Preview of crystalline_compound/compound_elastic_properties_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/compound_bulk_modulus.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: predict_bulk_modulus.py\n", + "execution_time_seconds": 660.3288373947144, + "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \nThe multi-agent workflow successfully completed the assigned task. The `agent builder` correctly identified the need to avoid data leakage by excluding other elastic properties (G_VRH, poisson_ratio, elastic_anisotropy) and engineered compositional features from the chemical formulas. It trained a Random Forest model as requested and saved the predictions to the exact specified path with the correct columns. The `agent grounded_validator` rigorously verified the execution by re-running the script, checking for data leakage, and validating the output file's format, row count, and prediction distributions, ensuring physical reasonability.\n\n**Strengths:**\n- Strict adherence to formatting and file path constraints.\n- Proper handling of data leakage by excluding target-correlated features from the test/train split.\n- Thorough validation by the grounded validator, ensuring the output is non-degenerate and physically plausible.\n\n**Weaknesses & Areas for Improvement:**\n- **Ignored Domain Knowledge:** The domain knowledge explicitly suggested using tools like `Matminer` to generate features from compositions and crystal structures. The builder ignored this and wrote a custom regex-based formula parser. While functional for this specific dataset, custom regex parsers are brittle and fail on complex or non-standard chemical notations, making the approach less robust and scalable.\n- **Underutilized Data:** The dataset included a `structure` column containing full crystallographic data (lattice parameters, atomic sites). The domain knowledge highlighted that crystal structure descriptors (coordination number, density, etc.) are highly valuable. The builder completely ignored this rich structural data, relying solely on basic compositional statistics and space group, which likely left significant predictive performance on the table. \n- **Library Usage:** Relying on established materials science libraries (`pymatgen`, `matminer`) rather than reinventing the wheel with custom parsers would have yielded a much more scalable and scientifically rigorous feature engineering pipeline.", + "total_eval": 3, + "git": { + "commit": "01e640fe023e47e7d1f047d0eec9795e11de96a1", + "branch": "mimosa_v2", + "dirty": true + }, + "capsule_name": "predict_bulk_modulus_rf", + "ver_success": 2, + "sr_success": 1, + "avg_cbs": 0.9139301796497762, + "total_cost": 5.0090959392, + "is_success": true, + "task_cost": 0.5688743194999998, + "max_judge_reward": 1.0, + "evolution_iterations": 1, + "evolved_workflows_uuids": [ + "20260607_162717_ed88c10e" + ], + "evolution_rewards": [ + 1.0 + ], + "evolution_costs": [ + 0.5688743194999998 + ], + "evolution_total_cost": 0.5688743194999998, + "evolution_avg_reward": 1.0, + "evolution_avg_cost": 0.5688743194999998 +} \ No newline at end of file diff --git a/sources/cache/openrouter_pricing.json b/sources/cache/openrouter_pricing.json deleted file mode 100644 index bcfa0d9..0000000 --- a/sources/cache/openrouter_pricing.json +++ /dev/null @@ -1,1369 +0,0 @@ -{ - "timestamp": "2026-06-09T12:20:21.879505", - "pricing": { - "nex-agi/nex-n2-pro:free": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-3.5-content-safety:free": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-3-ultra-550b-a55b:free": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-3-ultra-550b-a55b": { - "input": 0.5, - "output": 2.5 - }, - "qwen/qwen3.7-plus": { - "input": 0.39999999999999997, - "output": 1.5999999999999999 - }, - "minimax/minimax-m3": { - "input": 0.3, - "output": 1.2 - }, - "stepfun/step-3.7-flash": { - "input": 0.19999999999999998, - "output": 1.15 - }, - "anthropic/claude-opus-4.8-fast": { - "input": 10.0, - "output": 50.0 - }, - "anthropic/claude-opus-4.8": { - "input": 5.0, - "output": 25.0 - }, - "qwen/qwen3.7-max": { - "input": 1.25, - "output": 3.75 - }, - "x-ai/grok-build-0.1": { - "input": 1.0, - "output": 2.0 - }, - "google/gemini-3.5-flash": { - "input": 1.5, - "output": 9.0 - }, - "anthropic/claude-opus-4.7-fast": { - "input": 30.0, - "output": 150.0 - }, - "openrouter/fusion": { - "input": -1000000.0, - "output": -1000000.0 - }, - "perceptron/perceptron-mk1": { - "input": 0.15, - "output": 1.5 - }, - "inclusionai/ring-2.6-1t": { - "input": 0.075, - "output": 0.625 - }, - "google/gemini-3.1-flash-lite": { - "input": 0.25, - "output": 1.5 - }, - "openai/gpt-chat-latest": { - "input": 5.0, - "output": 30.0 - }, - "x-ai/grok-4.3": { - "input": 1.25, - "output": 2.5 - }, - "ibm-granite/granite-4.1-8b": { - "input": 0.049999999999999996, - "output": 0.09999999999999999 - }, - "mistralai/mistral-medium-3-5": { - "input": 1.5, - "output": 7.5 - }, - "openrouter/owl-alpha": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { - "input": 0.0, - "output": 0.0 - }, - "poolside/laguna-xs.2:free": { - "input": 0.0, - "output": 0.0 - }, - "poolside/laguna-m.1:free": { - "input": 0.0, - "output": 0.0 - }, - "~anthropic/claude-haiku-latest": { - "input": 1.0, - "output": 5.0 - }, - "~openai/gpt-mini-latest": { - "input": 0.75, - "output": 4.5 - }, - "~google/gemini-pro-latest": { - "input": 2.0, - "output": 12.0 - }, - "~moonshotai/kimi-latest": { - "input": 0.6799999999999999, - "output": 3.41 - }, - "~google/gemini-flash-latest": { - "input": 1.5, - "output": 9.0 - }, - "~anthropic/claude-sonnet-latest": { - "input": 3.0, - "output": 15.0 - }, - "~openai/gpt-latest": { - "input": 5.0, - "output": 30.0 - }, - "qwen/qwen3.5-plus-20260420": { - "input": 0.3, - "output": 1.7999999999999998 - }, - "qwen/qwen3.6-flash": { - "input": 0.1875, - "output": 1.125 - }, - "qwen/qwen3.6-35b-a3b": { - "input": 0.14, - "output": 1.0 - }, - "qwen/qwen3.6-max-preview": { - "input": 1.04, - "output": 6.24 - }, - "qwen/qwen3.6-27b": { - "input": 0.28900000000000003, - "output": 2.4 - }, - "openai/gpt-5.5-pro": { - "input": 30.0, - "output": 180.0 - }, - "openai/gpt-5.5": { - "input": 5.0, - "output": 30.0 - }, - "deepseek/deepseek-v4-pro": { - "input": 0.435, - "output": 0.87 - }, - "deepseek/deepseek-v4-flash": { - "input": 0.0983, - "output": 0.1966 - }, - "inclusionai/ling-2.6-1t": { - "input": 0.075, - "output": 0.625 - }, - "tencent/hy3-preview": { - "input": 0.063, - "output": 0.21 - }, - "xiaomi/mimo-v2.5-pro": { - "input": 0.435, - "output": 0.87 - }, - "xiaomi/mimo-v2.5": { - "input": 0.14, - "output": 0.28 - }, - "openai/gpt-5.4-image-2": { - "input": 8.0, - "output": 15.0 - }, - "inclusionai/ling-2.6-flash": { - "input": 0.01, - "output": 0.03 - }, - "~anthropic/claude-opus-latest": { - "input": 5.0, - "output": 25.0 - }, - "openrouter/pareto-code": { - "input": -1000000.0, - "output": -1000000.0 - }, - "moonshotai/kimi-k2.6:free": { - "input": 0.0, - "output": 0.0 - }, - "moonshotai/kimi-k2.6": { - "input": 0.6799999999999999, - "output": 3.41 - }, - "anthropic/claude-opus-4.7": { - "input": 5.0, - "output": 25.0 - }, - "anthropic/claude-opus-4.6-fast": { - "input": 30.0, - "output": 150.0 - }, - "z-ai/glm-5.1": { - "input": 0.98, - "output": 3.08 - }, - "google/gemma-4-26b-a4b-it:free": { - "input": 0.0, - "output": 0.0 - }, - "google/gemma-4-26b-a4b-it": { - "input": 0.06, - "output": 0.33 - }, - "google/gemma-4-31b-it:free": { - "input": 0.0, - "output": 0.0 - }, - "google/gemma-4-31b-it": { - "input": 0.12, - "output": 0.36 - }, - "qwen/qwen3.6-plus": { - "input": 0.325, - "output": 1.95 - }, - "z-ai/glm-5v-turbo": { - "input": 1.2, - "output": 4.0 - }, - "arcee-ai/trinity-large-thinking": { - "input": 0.22, - "output": 0.85 - }, - "x-ai/grok-4.20-multi-agent": { - "input": 2.0, - "output": 6.0 - }, - "x-ai/grok-4.20": { - "input": 1.25, - "output": 2.5 - }, - "google/lyria-3-pro-preview": { - "input": 0.0, - "output": 0.0 - }, - "google/lyria-3-clip-preview": { - "input": 0.0, - "output": 0.0 - }, - "kwaipilot/kat-coder-pro-v2": { - "input": 0.3, - "output": 1.2 - }, - "rekaai/reka-edge": { - "input": 0.09999999999999999, - "output": 0.09999999999999999 - }, - "minimax/minimax-m2.7": { - "input": 0.27899999999999997, - "output": 1.2 - }, - "openai/gpt-5.4-nano": { - "input": 0.19999999999999998, - "output": 1.25 - }, - "openai/gpt-5.4-mini": { - "input": 0.75, - "output": 4.5 - }, - "mistralai/mistral-small-2603": { - "input": 0.15, - "output": 0.6 - }, - "z-ai/glm-5-turbo": { - "input": 1.2, - "output": 4.0 - }, - "nvidia/nemotron-3-super-120b-a12b:free": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-3-super-120b-a12b": { - "input": 0.09, - "output": 0.44999999999999996 - }, - "bytedance-seed/seed-2.0-lite": { - "input": 0.25, - "output": 2.0 - }, - "qwen/qwen3.5-9b": { - "input": 0.09999999999999999, - "output": 0.15 - }, - "openai/gpt-5.4-pro": { - "input": 30.0, - "output": 180.0 - }, - "openai/gpt-5.4": { - "input": 2.5, - "output": 15.0 - }, - "inception/mercury-2": { - "input": 0.25, - "output": 0.75 - }, - "openai/gpt-5.3-chat": { - "input": 1.75, - "output": 14.0 - }, - "google/gemini-3.1-flash-lite-preview": { - "input": 0.25, - "output": 1.5 - }, - "bytedance-seed/seed-2.0-mini": { - "input": 0.09999999999999999, - "output": 0.39999999999999997 - }, - "google/gemini-3.1-flash-image-preview": { - "input": 0.5, - "output": 3.0 - }, - "qwen/qwen3.5-35b-a3b": { - "input": 0.14, - "output": 1.0 - }, - "qwen/qwen3.5-27b": { - "input": 0.195, - "output": 1.56 - }, - "qwen/qwen3.5-122b-a10b": { - "input": 0.26, - "output": 2.08 - }, - "qwen/qwen3.5-flash-02-23": { - "input": 0.065, - "output": 0.26 - }, - "liquid/lfm-2-24b-a2b": { - "input": 0.03, - "output": 0.12 - }, - "google/gemini-3.1-pro-preview-customtools": { - "input": 2.0, - "output": 12.0 - }, - "openai/gpt-5.3-codex": { - "input": 1.75, - "output": 14.0 - }, - "aion-labs/aion-2.0": { - "input": 0.7999999999999999, - "output": 1.5999999999999999 - }, - "google/gemini-3.1-pro-preview": { - "input": 2.0, - "output": 12.0 - }, - "anthropic/claude-sonnet-4.6": { - "input": 3.0, - "output": 15.0 - }, - "qwen/qwen3.5-plus-02-15": { - "input": 0.26, - "output": 1.56 - }, - "qwen/qwen3.5-397b-a17b": { - "input": 0.39, - "output": 2.34 - }, - "minimax/minimax-m2.5": { - "input": 0.15, - "output": 1.15 - }, - "z-ai/glm-5": { - "input": 0.6, - "output": 1.92 - }, - "qwen/qwen3-max-thinking": { - "input": 0.78, - "output": 3.9 - }, - "anthropic/claude-opus-4.6": { - "input": 5.0, - "output": 25.0 - }, - "qwen/qwen3-coder-next": { - "input": 0.11, - "output": 0.7999999999999999 - }, - "openrouter/free": { - "input": 0.0, - "output": 0.0 - }, - "stepfun/step-3.5-flash": { - "input": 0.09, - "output": 0.3 - }, - "moonshotai/kimi-k2.5": { - "input": 0.39999999999999997, - "output": 1.9 - }, - "upstage/solar-pro-3": { - "input": 0.15, - "output": 0.6 - }, - "minimax/minimax-m2-her": { - "input": 0.3, - "output": 1.2 - }, - "writer/palmyra-x5": { - "input": 0.6, - "output": 6.0 - }, - "liquid/lfm-2.5-1.2b-thinking:free": { - "input": 0.0, - "output": 0.0 - }, - "liquid/lfm-2.5-1.2b-instruct:free": { - "input": 0.0, - "output": 0.0 - }, - "openai/gpt-audio": { - "input": 2.5, - "output": 10.0 - }, - "openai/gpt-audio-mini": { - "input": 0.6, - "output": 2.4 - }, - "z-ai/glm-4.7-flash": { - "input": 0.06, - "output": 0.39999999999999997 - }, - "openai/gpt-5.2-codex": { - "input": 1.75, - "output": 14.0 - }, - "bytedance-seed/seed-1.6-flash": { - "input": 0.075, - "output": 0.3 - }, - "bytedance-seed/seed-1.6": { - "input": 0.25, - "output": 2.0 - }, - "minimax/minimax-m2.1": { - "input": 0.29, - "output": 0.95 - }, - "z-ai/glm-4.7": { - "input": 0.39999999999999997, - "output": 1.75 - }, - "google/gemini-3-flash-preview": { - "input": 0.5, - "output": 3.0 - }, - "xiaomi/mimo-v2-flash": { - "input": 0.09999999999999999, - "output": 0.3 - }, - "nvidia/nemotron-3-nano-30b-a3b:free": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-3-nano-30b-a3b": { - "input": 0.049999999999999996, - "output": 0.19999999999999998 - }, - "openai/gpt-5.2-chat": { - "input": 1.75, - "output": 14.0 - }, - "openai/gpt-5.2-pro": { - "input": 21.0, - "output": 168.0 - }, - "openai/gpt-5.2": { - "input": 1.75, - "output": 14.0 - }, - "mistralai/devstral-2512": { - "input": 0.39999999999999997, - "output": 2.0 - }, - "relace/relace-search": { - "input": 1.0, - "output": 3.0 - }, - "z-ai/glm-4.6v": { - "input": 0.3, - "output": 0.8999999999999999 - }, - "essentialai/rnj-1-instruct": { - "input": 0.15, - "output": 0.15 - }, - "openrouter/bodybuilder": { - "input": -1000000.0, - "output": -1000000.0 - }, - "openai/gpt-5.1-codex-max": { - "input": 1.25, - "output": 10.0 - }, - "amazon/nova-2-lite-v1": { - "input": 0.3, - "output": 2.5 - }, - "mistralai/ministral-14b-2512": { - "input": 0.19999999999999998, - "output": 0.19999999999999998 - }, - "mistralai/ministral-8b-2512": { - "input": 0.15, - "output": 0.15 - }, - "mistralai/ministral-3b-2512": { - "input": 0.09999999999999999, - "output": 0.09999999999999999 - }, - "mistralai/mistral-large-2512": { - "input": 0.5, - "output": 1.5 - }, - "arcee-ai/trinity-mini": { - "input": 0.045, - "output": 0.15 - }, - "deepseek/deepseek-v3.2": { - "input": 0.2288, - "output": 0.3432 - }, - "prime-intellect/intellect-3": { - "input": 0.19999999999999998, - "output": 1.1 - }, - "anthropic/claude-opus-4.5": { - "input": 5.0, - "output": 25.0 - }, - "allenai/olmo-3-32b-think": { - "input": 0.15, - "output": 0.5 - }, - "google/gemini-3-pro-image-preview": { - "input": 2.0, - "output": 12.0 - }, - "deepcogito/cogito-v2.1-671b": { - "input": 1.25, - "output": 1.25 - }, - "openai/gpt-5.1": { - "input": 1.25, - "output": 10.0 - }, - "openai/gpt-5.1-chat": { - "input": 1.25, - "output": 10.0 - }, - "openai/gpt-5.1-codex": { - "input": 1.25, - "output": 10.0 - }, - "openai/gpt-5.1-codex-mini": { - "input": 0.25, - "output": 2.0 - }, - "moonshotai/kimi-k2-thinking": { - "input": 0.6, - "output": 2.5 - }, - "amazon/nova-premier-v1": { - "input": 2.5, - "output": 12.5 - }, - "perplexity/sonar-pro-search": { - "input": 3.0, - "output": 15.0 - }, - "mistralai/voxtral-small-24b-2507": { - "input": 0.09999999999999999, - "output": 0.3 - }, - "openai/gpt-oss-safeguard-20b": { - "input": 0.075, - "output": 0.3 - }, - "nvidia/nemotron-nano-12b-v2-vl:free": { - "input": 0.0, - "output": 0.0 - }, - "minimax/minimax-m2": { - "input": 0.255, - "output": 1.0 - }, - "qwen/qwen3-vl-32b-instruct": { - "input": 0.10400000000000001, - "output": 0.41600000000000004 - }, - "ibm-granite/granite-4.0-h-micro": { - "input": 0.017, - "output": 0.112 - }, - "microsoft/phi-4-mini-instruct": { - "input": 0.08, - "output": 0.35 - }, - "openai/gpt-5-image-mini": { - "input": 2.5, - "output": 2.0 - }, - "anthropic/claude-haiku-4.5": { - "input": 1.0, - "output": 5.0 - }, - "qwen/qwen3-vl-8b-thinking": { - "input": 0.117, - "output": 1.365 - }, - "qwen/qwen3-vl-8b-instruct": { - "input": 0.08, - "output": 0.5 - }, - "openai/gpt-5-image": { - "input": 10.0, - "output": 10.0 - }, - "openai/o3-deep-research": { - "input": 10.0, - "output": 40.0 - }, - "openai/o4-mini-deep-research": { - "input": 2.0, - "output": 8.0 - }, - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - "input": 0.39999999999999997, - "output": 0.39999999999999997 - }, - "google/gemini-2.5-flash-image": { - "input": 0.3, - "output": 2.5 - }, - "qwen/qwen3-vl-30b-a3b-thinking": { - "input": 0.13, - "output": 1.56 - }, - "qwen/qwen3-vl-30b-a3b-instruct": { - "input": 0.13, - "output": 0.52 - }, - "openai/gpt-5-pro": { - "input": 15.0, - "output": 120.0 - }, - "z-ai/glm-4.6": { - "input": 0.43, - "output": 1.74 - }, - "anthropic/claude-sonnet-4.5": { - "input": 3.0, - "output": 15.0 - }, - "deepseek/deepseek-v3.2-exp": { - "input": 0.27, - "output": 0.41 - }, - "thedrummer/cydonia-24b-v4.1": { - "input": 0.3, - "output": 0.5 - }, - "relace/relace-apply-3": { - "input": 0.85, - "output": 1.25 - }, - "google/gemini-2.5-flash-lite-preview-09-2025": { - "input": 0.09999999999999999, - "output": 0.39999999999999997 - }, - "qwen/qwen3-vl-235b-a22b-thinking": { - "input": 0.26, - "output": 2.6 - }, - "qwen/qwen3-vl-235b-a22b-instruct": { - "input": 0.19999999999999998, - "output": 0.88 - }, - "qwen/qwen3-max": { - "input": 0.78, - "output": 3.9 - }, - "qwen/qwen3-coder-plus": { - "input": 0.65, - "output": 3.25 - }, - "openai/gpt-5-codex": { - "input": 1.25, - "output": 10.0 - }, - "deepseek/deepseek-v3.1-terminus": { - "input": 0.27, - "output": 0.95 - }, - "qwen/qwen3-coder-flash": { - "input": 0.195, - "output": 0.975 - }, - "qwen/qwen3-next-80b-a3b-thinking": { - "input": 0.0975, - "output": 0.78 - }, - "qwen/qwen3-next-80b-a3b-instruct:free": { - "input": 0.0, - "output": 0.0 - }, - "qwen/qwen3-next-80b-a3b-instruct": { - "input": 0.09, - "output": 1.1 - }, - "qwen/qwen-plus-2025-07-28:thinking": { - "input": 0.26, - "output": 0.78 - }, - "qwen/qwen-plus-2025-07-28": { - "input": 0.26, - "output": 0.78 - }, - "nvidia/nemotron-nano-9b-v2:free": { - "input": 0.0, - "output": 0.0 - }, - "nvidia/nemotron-nano-9b-v2": { - "input": 0.04, - "output": 0.16 - }, - "moonshotai/kimi-k2-0905": { - "input": 0.6, - "output": 2.5 - }, - "qwen/qwen3-30b-a3b-thinking-2507": { - "input": 0.08, - "output": 0.39999999999999997 - }, - "nousresearch/hermes-4-70b": { - "input": 0.13, - "output": 0.39999999999999997 - }, - "nousresearch/hermes-4-405b": { - "input": 1.0, - "output": 3.0 - }, - "deepseek/deepseek-chat-v3.1": { - "input": 0.21, - "output": 0.7899999999999999 - }, - "mistralai/mistral-medium-3.1": { - "input": 0.39999999999999997, - "output": 2.0 - }, - "z-ai/glm-4.5v": { - "input": 0.6, - "output": 1.7999999999999998 - }, - "ai21/jamba-large-1.7": { - "input": 2.0, - "output": 8.0 - }, - "openai/gpt-5-chat": { - "input": 1.25, - "output": 10.0 - }, - "openai/gpt-5": { - "input": 1.25, - "output": 10.0 - }, - "openai/gpt-5-mini": { - "input": 0.25, - "output": 2.0 - }, - "openai/gpt-5-nano": { - "input": 0.049999999999999996, - "output": 0.39999999999999997 - }, - "openai/gpt-oss-120b:free": { - "input": 0.0, - "output": 0.0 - }, - "openai/gpt-oss-120b": { - "input": 0.039, - "output": 0.18 - }, - "openai/gpt-oss-20b:free": { - "input": 0.0, - "output": 0.0 - }, - "openai/gpt-oss-20b": { - "input": 0.029, - "output": 0.14 - }, - "anthropic/claude-opus-4.1": { - "input": 15.0, - "output": 75.0 - }, - "mistralai/codestral-2508": { - "input": 0.3, - "output": 0.8999999999999999 - }, - "qwen/qwen3-coder-30b-a3b-instruct": { - "input": 0.07, - "output": 0.27 - }, - "qwen/qwen3-30b-a3b-instruct-2507": { - "input": 0.04815, - "output": 0.19305 - }, - "z-ai/glm-4.5": { - "input": 0.6, - "output": 2.2 - }, - "z-ai/glm-4.5-air:free": { - "input": 0.0, - "output": 0.0 - }, - "z-ai/glm-4.5-air": { - "input": 0.125, - "output": 0.85 - }, - "qwen/qwen3-235b-a22b-thinking-2507": { - "input": 0.09999999999999999, - "output": 0.09999999999999999 - }, - "z-ai/glm-4-32b": { - "input": 0.09999999999999999, - "output": 0.09999999999999999 - }, - "qwen/qwen3-coder:free": { - "input": 0.0, - "output": 0.0 - }, - "qwen/qwen3-coder": { - "input": 0.22, - "output": 1.7999999999999998 - }, - "bytedance/ui-tars-1.5-7b": { - "input": 0.09999999999999999, - "output": 0.19999999999999998 - }, - "google/gemini-2.5-flash-lite": { - "input": 0.09999999999999999, - "output": 0.39999999999999997 - }, - "qwen/qwen3-235b-a22b-2507": { - "input": 0.09, - "output": 0.09999999999999999 - }, - "switchpoint/router": { - "input": 0.85, - "output": 3.4 - }, - "moonshotai/kimi-k2": { - "input": 0.5700000000000001, - "output": 2.3 - }, - "cognitivecomputations/dolphin-mistral-24b-venice-edition:free": { - "input": 0.0, - "output": 0.0 - }, - "tencent/hunyuan-a13b-instruct": { - "input": 0.14, - "output": 0.5700000000000001 - }, - "morph/morph-v3-large": { - "input": 0.8999999999999999, - "output": 1.9 - }, - "morph/morph-v3-fast": { - "input": 0.7999999999999999, - "output": 1.2 - }, - "baidu/ernie-4.5-vl-424b-a47b": { - "input": 0.42, - "output": 1.25 - }, - "mistralai/mistral-small-3.2-24b-instruct": { - "input": 0.075, - "output": 0.19999999999999998 - }, - "minimax/minimax-m1": { - "input": 0.39999999999999997, - "output": 2.2 - }, - "google/gemini-2.5-flash": { - "input": 0.3, - "output": 2.5 - }, - "google/gemini-2.5-pro": { - "input": 1.25, - "output": 10.0 - }, - "openai/o3-pro": { - "input": 20.0, - "output": 80.0 - }, - "google/gemini-2.5-pro-preview": { - "input": 1.25, - "output": 10.0 - }, - "deepseek/deepseek-r1-0528": { - "input": 0.5, - "output": 2.1500000000000004 - }, - "anthropic/claude-opus-4": { - "input": 15.0, - "output": 75.0 - }, - "anthropic/claude-sonnet-4": { - "input": 3.0, - "output": 15.0 - }, - "google/gemma-3n-e4b-it": { - "input": 0.06, - "output": 0.12 - }, - "mistralai/mistral-medium-3": { - "input": 0.39999999999999997, - "output": 2.0 - }, - "google/gemini-2.5-pro-preview-05-06": { - "input": 1.25, - "output": 10.0 - }, - "arcee-ai/maestro-reasoning": { - "input": 0.8999999999999999, - "output": 3.3000000000000003 - }, - "arcee-ai/virtuoso-large": { - "input": 0.75, - "output": 1.2 - }, - "arcee-ai/coder-large": { - "input": 0.5, - "output": 0.7999999999999999 - }, - "meta-llama/llama-guard-4-12b": { - "input": 0.18, - "output": 0.18 - }, - "qwen/qwen3-30b-a3b": { - "input": 0.12, - "output": 0.5 - }, - "qwen/qwen3-8b": { - "input": 0.049999999999999996, - "output": 0.39999999999999997 - }, - "qwen/qwen3-14b": { - "input": 0.09999999999999999, - "output": 0.24 - }, - "qwen/qwen3-32b": { - "input": 0.08, - "output": 0.28 - }, - "qwen/qwen3-235b-a22b": { - "input": 0.45499999999999996, - "output": 1.8199999999999998 - }, - "openai/o4-mini-high": { - "input": 1.1, - "output": 4.4 - }, - "openai/o3": { - "input": 2.0, - "output": 8.0 - }, - "openai/o4-mini": { - "input": 1.1, - "output": 4.4 - }, - "openai/gpt-4.1": { - "input": 2.0, - "output": 8.0 - }, - "openai/gpt-4.1-mini": { - "input": 0.39999999999999997, - "output": 1.5999999999999999 - }, - "openai/gpt-4.1-nano": { - "input": 0.09999999999999999, - "output": 0.39999999999999997 - }, - "meta-llama/llama-4-maverick": { - "input": 0.15, - "output": 0.6 - }, - "meta-llama/llama-4-scout": { - "input": 0.09999999999999999, - "output": 0.3 - }, - "deepseek/deepseek-chat-v3-0324": { - "input": 0.19999999999999998, - "output": 0.77 - }, - "openai/o1-pro": { - "input": 150.0, - "output": 600.0 - }, - "mistralai/mistral-small-3.1-24b-instruct": { - "input": 0.351, - "output": 0.5549999999999999 - }, - "google/gemma-3-4b-it": { - "input": 0.049999999999999996, - "output": 0.09999999999999999 - }, - "google/gemma-3-12b-it": { - "input": 0.049999999999999996, - "output": 0.15 - }, - "cohere/command-a": { - "input": 2.5, - "output": 10.0 - }, - "openai/gpt-4o-mini-search-preview": { - "input": 0.15, - "output": 0.6 - }, - "openai/gpt-4o-search-preview": { - "input": 2.5, - "output": 10.0 - }, - "rekaai/reka-flash-3": { - "input": 0.09999999999999999, - "output": 0.19999999999999998 - }, - "google/gemma-3-27b-it": { - "input": 0.08, - "output": 0.16 - }, - "thedrummer/skyfall-36b-v2": { - "input": 0.55, - "output": 0.7999999999999999 - }, - "perplexity/sonar-reasoning-pro": { - "input": 2.0, - "output": 8.0 - }, - "perplexity/sonar-pro": { - "input": 3.0, - "output": 15.0 - }, - "perplexity/sonar-deep-research": { - "input": 2.0, - "output": 8.0 - }, - "mistralai/mistral-saba": { - "input": 0.19999999999999998, - "output": 0.6 - }, - "meta-llama/llama-guard-3-8b": { - "input": 0.48400000000000004, - "output": 0.03 - }, - "openai/o3-mini-high": { - "input": 1.1, - "output": 4.4 - }, - "aion-labs/aion-1.0": { - "input": 4.0, - "output": 8.0 - }, - "aion-labs/aion-1.0-mini": { - "input": 0.7, - "output": 1.4 - }, - "aion-labs/aion-rp-llama-3.1-8b": { - "input": 0.7999999999999999, - "output": 1.5999999999999999 - }, - "qwen/qwen2.5-vl-72b-instruct": { - "input": 0.25, - "output": 0.75 - }, - "qwen/qwen-plus": { - "input": 0.26, - "output": 0.78 - }, - "openai/o3-mini": { - "input": 1.1, - "output": 4.4 - }, - "mistralai/mistral-small-24b-instruct-2501": { - "input": 0.049999999999999996, - "output": 0.08 - }, - "deepseek/deepseek-r1-distill-qwen-32b": { - "input": 0.29, - "output": 0.29 - }, - "perplexity/sonar": { - "input": 1.0, - "output": 1.0 - }, - "deepseek/deepseek-r1-distill-llama-70b": { - "input": 0.7, - "output": 0.7999999999999999 - }, - "deepseek/deepseek-r1": { - "input": 0.7, - "output": 2.5 - }, - "minimax/minimax-01": { - "input": 0.19999999999999998, - "output": 1.1 - }, - "microsoft/phi-4": { - "input": 0.065, - "output": 0.14 - }, - "sao10k/l3.1-70b-hanami-x1": { - "input": 3.0, - "output": 3.0 - }, - "deepseek/deepseek-chat": { - "input": 0.20020000000000002, - "output": 0.8000999999999999 - }, - "sao10k/l3.3-euryale-70b": { - "input": 0.65, - "output": 0.75 - }, - "openai/o1": { - "input": 15.0, - "output": 60.0 - }, - "cohere/command-r7b-12-2024": { - "input": 0.0375, - "output": 0.15 - }, - "meta-llama/llama-3.3-70b-instruct:free": { - "input": 0.0, - "output": 0.0 - }, - "meta-llama/llama-3.3-70b-instruct": { - "input": 0.09999999999999999, - "output": 0.32 - }, - "amazon/nova-lite-v1": { - "input": 0.06, - "output": 0.24 - }, - "amazon/nova-micro-v1": { - "input": 0.035, - "output": 0.14 - }, - "amazon/nova-pro-v1": { - "input": 0.7999999999999999, - "output": 3.1999999999999997 - }, - "openai/gpt-4o-2024-11-20": { - "input": 2.5, - "output": 10.0 - }, - "mistralai/mistral-large-2407": { - "input": 2.0, - "output": 6.0 - }, - "qwen/qwen-2.5-coder-32b-instruct": { - "input": 0.66, - "output": 1.0 - }, - "thedrummer/unslopnemo-12b": { - "input": 0.39999999999999997, - "output": 0.39999999999999997 - }, - "anthropic/claude-3.5-haiku": { - "input": 0.7999999999999999, - "output": 4.0 - }, - "anthracite-org/magnum-v4-72b": { - "input": 3.0, - "output": 5.0 - }, - "qwen/qwen-2.5-7b-instruct": { - "input": 0.04, - "output": 0.09999999999999999 - }, - "inflection/inflection-3-productivity": { - "input": 2.5, - "output": 10.0 - }, - "inflection/inflection-3-pi": { - "input": 2.5, - "output": 10.0 - }, - "thedrummer/rocinante-12b": { - "input": 0.16999999999999998, - "output": 0.43 - }, - "meta-llama/llama-3.2-3b-instruct:free": { - "input": 0.0, - "output": 0.0 - }, - "meta-llama/llama-3.2-3b-instruct": { - "input": 0.0509, - "output": 0.335 - }, - "meta-llama/llama-3.2-1b-instruct": { - "input": 0.027, - "output": 0.201 - }, - "meta-llama/llama-3.2-11b-vision-instruct": { - "input": 0.345, - "output": 0.345 - }, - "qwen/qwen-2.5-72b-instruct": { - "input": 0.36, - "output": 0.39999999999999997 - }, - "cohere/command-r-plus-08-2024": { - "input": 2.5, - "output": 10.0 - }, - "cohere/command-r-08-2024": { - "input": 0.15, - "output": 0.6 - }, - "sao10k/l3.1-euryale-70b": { - "input": 0.85, - "output": 0.85 - }, - "nousresearch/hermes-3-llama-3.1-70b": { - "input": 0.7, - "output": 0.7 - }, - "nousresearch/hermes-3-llama-3.1-405b:free": { - "input": 0.0, - "output": 0.0 - }, - "nousresearch/hermes-3-llama-3.1-405b": { - "input": 1.0, - "output": 1.0 - }, - "sao10k/l3-lunaris-8b": { - "input": 0.04, - "output": 0.049999999999999996 - }, - "openai/gpt-4o-2024-08-06": { - "input": 2.5, - "output": 10.0 - }, - "meta-llama/llama-3.1-8b-instruct": { - "input": 0.02, - "output": 0.03 - }, - "meta-llama/llama-3.1-70b-instruct": { - "input": 0.39999999999999997, - "output": 0.39999999999999997 - }, - "mistralai/mistral-nemo": { - "input": 0.02, - "output": 0.03 - }, - "openai/gpt-4o-mini-2024-07-18": { - "input": 0.15, - "output": 0.6 - }, - "openai/gpt-4o-mini": { - "input": 0.15, - "output": 0.6 - }, - "google/gemma-2-27b-it": { - "input": 0.65, - "output": 0.65 - }, - "openai/gpt-4o-2024-05-13": { - "input": 5.0, - "output": 15.0 - }, - "openai/gpt-4o": { - "input": 2.5, - "output": 10.0 - }, - "meta-llama/llama-3-8b-instruct": { - "input": 0.14, - "output": 0.14 - }, - "meta-llama/llama-3-70b-instruct": { - "input": 0.51, - "output": 0.74 - }, - "mistralai/mixtral-8x22b-instruct": { - "input": 2.0, - "output": 6.0 - }, - "microsoft/wizardlm-2-8x22b": { - "input": 0.62, - "output": 0.62 - }, - "openai/gpt-4-turbo": { - "input": 10.0, - "output": 30.0 - }, - "anthropic/claude-3-haiku": { - "input": 0.25, - "output": 1.25 - }, - "mistralai/mistral-large": { - "input": 2.0, - "output": 6.0 - }, - "openai/gpt-4-turbo-preview": { - "input": 10.0, - "output": 30.0 - }, - "openai/gpt-3.5-turbo-0613": { - "input": 1.0, - "output": 2.0 - }, - "openrouter/auto": { - "input": -1000000.0, - "output": -1000000.0 - }, - "openai/gpt-3.5-turbo-instruct": { - "input": 1.5, - "output": 2.0 - }, - "openai/gpt-3.5-turbo-16k": { - "input": 3.0, - "output": 4.0 - }, - "mancer/weaver": { - "input": 0.75, - "output": 1.0 - }, - "undi95/remm-slerp-l2-13b": { - "input": 0.44999999999999996, - "output": 0.65 - }, - "gryphe/mythomax-l2-13b": { - "input": 0.06, - "output": 0.06 - }, - "openai/gpt-4": { - "input": 30.0, - "output": 60.0 - }, - "openai/gpt-3.5-turbo": { - "input": 0.5, - "output": 1.5 - } - } -} \ No newline at end of file diff --git a/sources/evaluation/csv_mode.py b/sources/evaluation/csv_mode.py index e06a5b6..a78192e 100644 --- a/sources/evaluation/csv_mode.py +++ b/sources/evaluation/csv_mode.py @@ -31,7 +31,7 @@ ) -_INPUT_TIMEOUT = 10 # seconds before auto-accepting the default +_INPUT_TIMEOUT = 30 # seconds before auto-accepting the default async def _input_with_timeout(prompt: str, default: str = "0", timeout: float = _INPUT_TIMEOUT) -> str: diff --git a/workflow_evolution_anim.py b/workflow_evolution_anim.py index 5ade1c3..5f20468 100644 --- a/workflow_evolution_anim.py +++ b/workflow_evolution_anim.py @@ -708,16 +708,32 @@ def draw_rubric(surf, area, fonts, workflows: List[Workflow], return # Reserve room for the line chart at the bottom. - line_panel_h = min(max(70, area.height // 3), 110) + line_panel_h = min(max(45, area.height // 5), 60) grid_top = area.y grid_bottom = area.bottom - line_panel_h grid_h = grid_bottom - grid_top - # Pick the most-important claims that fit (≥14 px per row). + # Pick the most-important claims that fit (≥12 px per row). label_font = fonts["tiny"] - row_h_min = max(label_font.get_height() + 2, 14) + row_h_min = max(label_font.get_height(), 12) max_rows = max(1, grid_h // row_h_min) - claims = sorted(all_claims, key=lambda kv: -kv[1])[:max_rows] + + # Prioritise claims that exist in the *current* workflow so the + # heatmap is relevant to what the user is actually looking at. + current_wf = ( + workflows[current_idx] + if 0 <= current_idx < len(workflows) + else None + ) + current_names: set = set() + if current_wf and current_wf.evaluation: + current_names = {c.name for c in current_wf.evaluation.claims} + + current_claims = [c for c in all_claims if c[0] in current_names] + other_claims = [c for c in all_claims if c[0] not in current_names] + claims = current_claims[:max_rows] + if len(claims) < max_rows: + claims.extend(other_claims[: max_rows - len(claims)]) # Keep original first-seen order so the heatmap reads top-down. name_order = {n: i for i, (n, _) in enumerate(all_claims)} claims.sort(key=lambda kv: name_order[kv[0]]) @@ -791,6 +807,21 @@ def draw_rubric(surf, area, fonts, workflows: List[Workflow], ) t = fonts["small"].render(info, True, TEXT) surf.blit(t, (area.x, line_y1 - t.get_height() - 2)) + # claim count indicator + total_current = len(current_names) + total_all = len(all_claims) + if total_current > 0: + count_txt = ( + f"showing {len(claims)}/{total_current} current claims" + f" ({total_all} total)" + ) + else: + count_txt = f"showing {len(claims)}/{total_all} claims" + count_surf = label_font.render(count_txt, True, TEXT_FAINT) + # place at the right edge of the line-chart area + cx = grid_x + grid_w - count_surf.get_width() + cy = line_y1 - count_surf.get_height() - 2 + surf.blit(count_surf, (cx, cy)) # --------------------------------------------------------------------------- @@ -1029,7 +1060,7 @@ def _layout(self) -> Dict[str, pygame.Rect]: wfpng = pygame.Rect(pad, tree.bottom + pad, left_w, body_bottom - tree.bottom - pad) - timelapse_h = int((body_bottom - body_top) * 0.66) - pad // 2 + timelapse_h = int((body_bottom - body_top) * 0.50) - pad // 2 timelapse = pygame.Rect(right_x, body_top, right_w, timelapse_h) rubric = pygame.Rect(right_x, timelapse.bottom + pad, right_w, body_bottom - timelapse.bottom - pad) From 30f0c14a8097b2276f83b3797e791cf544736690 Mon Sep 17 00:00:00 2001 From: martin Date: Thu, 11 Jun 2026 16:02:09 +0200 Subject: [PATCH 03/71] fix(workflow factory): max_tokens set to 16384 to avoid max token crash --- sources/core/workflow_factory.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sources/core/workflow_factory.py b/sources/core/workflow_factory.py index 3d7daab..4e5747c 100644 --- a/sources/core/workflow_factory.py +++ b/sources/core/workflow_factory.py @@ -153,7 +153,7 @@ def llm_make_workflow( model=model, provider=provider, reasoning_effort=self.config.reasoning_effort, - max_tokens=getattr(self.config, 'max_tokens', 8192), + max_tokens=16384, openrouter_provider=None, # use default ) return LLMProvider("workflow_creator", path, system_prompt, llm_config)(prompt, use_cache=allow_cache) @@ -493,4 +493,7 @@ async def craft_workflow( self.logger.debug(f"Workflow path: {workflow_path}") self.logger.debug(f"Memory path: {memory_path}") + print(f"Workflow generated with UUID: {uuid_str}") + print(complete_code) + return complete_code, workflow_genotype_code, uuid_str From b2da8c7b9071048b5476ba7b068fa12b85b9839d Mon Sep 17 00:00:00 2001 From: martin Date: Thu, 11 Jun 2026 16:06:42 +0200 Subject: [PATCH 04/71] feat(orchestrator): test main --- sources/core/orchestrator.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/sources/core/orchestrator.py b/sources/core/orchestrator.py index bb6c32d..ceef75a 100644 --- a/sources/core/orchestrator.py +++ b/sources/core/orchestrator.py @@ -91,7 +91,7 @@ def progress_handler(line: str) -> None: else: print_err(f"Workflow execution failed: {result.stderr}") raise Exception(f"Workflow execution failed: {result.stderr}") - + def perspicacite_grounding_task(self, task: str) -> str: """Query Perspicacite-AI for a literature-grounded approach to a task. @@ -373,3 +373,19 @@ def __del__(self) -> None: pass except Exception: pass + +async def test_workflow_orchestrator(): + evolution_prommt = ''' +put here evolution prompt to test +''' + goal = "Given the target structure of acetylcholinesterase, design a 5-amino-acid peptide sequence that would act as an irreversible inhibitor. Provide the sequence and predicted binding affinity." + from config import Config + config = Config() + orch = WorkflowOrchestrator(config) + await orch._generate_workflow_code( + goal, evolution_prommt, single_agent_mode=False, original_task=goal + ) + +if __name__ == "__main__": + import asyncio + asyncio.run(test_workflow_orchestrator()) \ No newline at end of file From 2b1ab46cea025c9ac05dfba9b90135232855f4ac Mon Sep 17 00:00:00 2001 From: martin Date: Thu, 11 Jun 2026 16:13:05 +0200 Subject: [PATCH 05/71] feat(verifier): shorter prompt gradient --- sources/core/evaluators/verifier.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sources/core/evaluators/verifier.py b/sources/core/evaluators/verifier.py index e3e0f05..f01a46c 100644 --- a/sources/core/evaluators/verifier.py +++ b/sources/core/evaluators/verifier.py @@ -189,8 +189,10 @@ def _build_abstractec_textual_gradient(self, uuid: str, report: str, execution_t {execution_text} Here is the deterministic verifier's detailed report for workflow {uuid}: {report} + + Focus on the report on the most important claims and the most actionable diagnosis. Be concise and specific, avoid vague language. Do not mention the importance but order the claims by importance. OUTPUT: - Format: ":\n<- \n<- \n... (up to 25 lines of diagnosis)" + Format: ":\n<- \n<- \n... (up to 7 lines of diagnosis maximum)" Example: FALLBACK_ECFP_CLASSIFIER:\n-Use of fallback rather than a trained ECFP classifier-\n- Error with numpy: ...\nNo requirements.txt found.... """ From 2878c020cf29824fb1b2b9b4134ec9c8e19a4f87 Mon Sep 17 00:00:00 2001 From: martin Date: Thu, 11 Jun 2026 17:18:27 +0200 Subject: [PATCH 06/71] feat(qd): centered failure-fingerprint descriptor for QD novelty --- docs/DEVELOPER_GUIDE.md | 3 +- docs/concepts/evaluation-pipeline.md | 39 +++++ docs/concepts/evolution-engine.md | 47 +++++- docs/diagrams/evolution_loop.mermaid | 8 +- docs/info-flow/failure_fingerprint.md | 169 +++++++++++++++++++++ docs/reference/file-layout.md | 3 +- sources/core/evaluators/verifier.py | 14 ++ sources/core/failure_fingerprint.py | 208 ++++++++++++++++++++++++++ sources/core/selection.py | 63 ++++++-- tests/failure_fingerprint_test.py | 166 ++++++++++++++++++++ tests/selection_test.py | 52 +++++-- 11 files changed, 739 insertions(+), 33 deletions(-) create mode 100644 docs/info-flow/failure_fingerprint.md create mode 100644 sources/core/failure_fingerprint.py create mode 100644 tests/failure_fingerprint_test.py diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 19b26da..7d2cf29 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -105,7 +105,8 @@ mimosa-ai/ │ │ ├── selection.py # SelectionPressure (greedy/tournament/novelty/QD) │ │ ├── variation_engine.py # Mutation/crossover prompt assembly + annealing │ │ ├── workflow_selection.py # Parent retrieval (archive draw / disk scan) -│ │ ├── code_features.py # AST → behaviour descriptor (4-vector) +│ │ ├── failure_fingerprint.py # Verifier verdicts → QD behaviour descriptor (6-D, centered) +│ │ ├── code_features.py # Legacy structural descriptor (offline analysis only) │ │ ├── lineage.py # parent → child sidecar records │ │ ├── orchestrator.py # Grounding → factory → sandbox pipeline │ │ ├── workflow_factory.py # Multi-agent workflow synthesis diff --git a/docs/concepts/evaluation-pipeline.md b/docs/concepts/evaluation-pipeline.md index 3bee08d..fa8f467 100644 --- a/docs/concepts/evaluation-pipeline.md +++ b/docs/concepts/evaluation-pipeline.md @@ -20,6 +20,12 @@ fingerprint). The single signal that flows back to the mutator is a short **prompt gradient** that summarizes failure modes without leaking the verified claims themselves. +The same per-claim verdicts are projected into a 6-dim **failure +fingerprint** that the [evolution engine](evolution-engine.md#behaviour-descriptor-failure-fingerprint) +uses as the behaviour descriptor for QD novelty. The descriptor is +centered so overall quality cannot leak into novelty — see the firewall +section below. + ![Evaluation pipeline](../images/evaluation_pipeline.png){ width="100%" } ## The pipeline @@ -122,6 +128,39 @@ overall_score = min(pre_cap, hard_fail_cap) if any_hard_claim_refuted else pre_c - The engine separately keeps `overall_score_uncapped` (pre-cap) so QD rank ordering doesn't flatten under hard fails. +## Failure fingerprint (QD behaviour descriptor) + +The verifier doesn't just emit a score — the same per-claim verdicts feed +the QD novelty signal as a **failure fingerprint**: a centered vector +of per-source pass rates that tells the archive *how* a candidate fails, +not *whether* it failed. + +```python +# Per source A..F (six entries, always — absent sources get a neutral value). +pass_rate[s] = passes[s] / total[s] if total[s] > 0 else 0.5 +presence[s] = 1.0 if total[s] > 0 else 0.0 +# Center so the descriptor encodes profile shape, not quality level. +mean_present = mean(pass_rate[s] for s where presence[s] == 1) +vector[s] = pass_rate[s] - mean_present if presence[s] == 1 + = 0 otherwise +``` + +**The quality firewall.** An all-pass run and an all-fail run both yield +the zero profile. This is intended and asserted in the tests +(`test_all_pass_yields_zero_profile`, +`test_all_fail_yields_zero_profile`). The QD score combines quality and +novelty *additively* — `(1 − w)·quality_norm + w·novelty_norm` — so +quality already drives `quality_norm`. If quality also leaked into +novelty, QD would collapse back into greedy search. The centering step +is what keeps these two terms separable. + +The fingerprint is persisted under +`state_result.json` → `evaluation.verifier.failure_fingerprint.vector` +and consumed by +[`SelectionPressure._extract_behaviour_descriptor`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/selection.py). +Full info-flow audit: +[`docs/info-flow/failure_fingerprint.md`](../info-flow/failure_fingerprint.md). + ## Prompt gradient After aggregation the verifier composes a single-sentence diagnosis (the diff --git a/docs/concepts/evolution-engine.md b/docs/concepts/evolution-engine.md index a2e76f2..b66379d 100644 --- a/docs/concepts/evolution-engine.md +++ b/docs/concepts/evolution-engine.md @@ -64,12 +64,15 @@ implements four strategies — `greedy`, `tournament`, `novelty`, and `qd` - A **session archive** holds up to `population_size = 50` members. - Each member has `qd_score = (1−w)·quality_norm + w·novelty_norm`, with - `w = novelty_weight = 0.25`. + `w = novelty_weight = 0.4`. Quality and novelty are **additive** — never + multiplied — so high quality cannot rescue a redundant profile and high + novelty cannot drag a broken run above peers. - Quality is sourced from `reward_uncapped` so the hard-fail cap doesn't flatten rank ordering. -- Novelty is k-NN distance (`k = 15`) in behaviour-descriptor space, where - the descriptor is `[n_agents, n_edges, n_branches, prompt_chars]` - extracted by [`code_features.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/code_features.py). +- Novelty is k-NN distance (`k = 25`) in **failure-fingerprint** space. + The descriptor is the centered per-source pass-rate vector produced by + [`failure_fingerprint.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/failure_fingerprint.py) + from the verifier's per-claim verdicts (see below). - Admission gate: candidate is admitted when it either improves over baseline by `min_improvement_threshold` or clears `qd_score > admit_threshold`. When the archive reaches capacity, the @@ -84,6 +87,42 @@ falls back to a **similarity-filtered disk scan** (`cosine ≥ 0.5` on MiniLM embeddings of `original_task`, `score ≥ 0.05`) — this lets useful workflows transfer across tasks. +### Behaviour descriptor: failure fingerprint + +The novelty signal compares candidates in **failure-fingerprint** space. +Per source A–F (literature, user goal, agent narration, math invariants, +computational reproducibility, statistical fingerprint), the verifier +records a pass rate. Sources with zero claims get the neutral value +`0.5` and a presence-mask entry of `0`. The vector is then **centered**: +the mean pass rate across present sources is subtracted from every entry. + +The centering is the *quality firewall*. Without it, an all-pass run sits +at `[1,1,1,1,1,1]` and an all-fail run at `[0,0,0,0,0,0]` — Euclidean +distance between them is large, and quality silently leaks into novelty. +After centering, **both** runs collapse to the zero profile and novelty +encodes only the *shape* of which sources fail relative to the others. +Two workflows that fail in the same way are redundant regardless of how +different their DAGs look; two that fail in different ways explore +different basins and both deserve a seat in the archive. + +The fingerprint is computed at the end of `VerifierEvaluator.evaluate()` +and persisted in `state_result.json` under +`evaluation.verifier.failure_fingerprint.vector`. The full audit trail — +which variable comes from where, the failure modes the descriptor must +survive, and the centering invariant asserted by the tests — lives in +[`docs/info-flow/failure_fingerprint.md`](../info-flow/failure_fingerprint.md). + +When a run has no usable fingerprint (verifier short-circuit on a fully +failed workflow), `SelectionPressure._extract_behaviour_descriptor` +returns a neutral zero vector so distance lookups stay well-defined and +the cold path doesn't artificially win or lose on novelty. + +The legacy structural descriptor (`[n_agents, n_edges, n_branches, +prompt_chars]`) shipped by +[`code_features.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/code_features.py) +is retained for ablations and offline analysis but is **no longer used** +for QD novelty — empirical work showed it barely co-varies with outcomes. + ## Variation: evidence-driven mutation scope (Rechenberg 1/5 rule) [`VariationEngine`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/variation_engine.py) diff --git a/docs/diagrams/evolution_loop.mermaid b/docs/diagrams/evolution_loop.mermaid index 5ec27bd..6152222 100644 --- a/docs/diagrams/evolution_loop.mermaid +++ b/docs/diagrams/evolution_loop.mermaid @@ -79,11 +79,11 @@ flowchart TB %% ===== sidebars ===== subgraph QD["Quality-Diversity machinery"] - BD["behaviour descriptor
extract_code_features() →
[n_agents, n_edges, n_branches,
prompt_chars] / SCALES"] - Nov["k-NN novelty
(k=15, against archive)"] - QDscore["qd_score = (1-w)·quality_norm +
w·novelty_norm (w=0.25)
quality from reward_uncapped"] + BD["behaviour descriptor
failure_fingerprint(per_claim) →
centered per-source pass rates
over sources A..F (6-D)"] + Nov["k-NN novelty
(k=25, against archive)"] + QDscore["qd_score = (1-w)·quality_norm +
w·novelty_norm (w=0.4)
quality from reward_uncapped
(additive: no quality leak into novelty)"] end - State -. AST .-> BD + State -. verifier per-claim verdicts .-> BD Archive -. distances .-> Nov BD --> QDscore Nov --> QDscore diff --git a/docs/info-flow/failure_fingerprint.md b/docs/info-flow/failure_fingerprint.md new file mode 100644 index 0000000..71b2c03 --- /dev/null +++ b/docs/info-flow/failure_fingerprint.md @@ -0,0 +1,169 @@ +# Failure-fingerprint descriptor — info flow + +> **Reader's note.** This page is an info-flow audit, not a user-facing +> tutorial. It traces every variable that feeds the QD behaviour descriptor +> from its source to the point where novelty distance is computed. +> If you want to *understand* the descriptor, start with +> [`concepts/evolution-engine.md`](../concepts/evolution-engine.md) and +> [`concepts/evaluation-pipeline.md`](../concepts/evaluation-pipeline.md). + +## Why a separate descriptor + +QD selection compares candidates with two scalars: + +- **quality** — `reward_uncapped`, i.e. how well the workflow scored. +- **novelty** — k-NN distance in *behaviour-descriptor space* to the + rest of the archive. + +The descriptor must be **orthogonal to quality**. If it isn't, novelty +becomes a noisy restatement of quality and QD collapses back into greedy +search. The previous structural descriptor +(`[n_agents, n_edges, n_branches, prompt_chars]`) was nominally +orthogonal but barely co-varied with outcomes — different DAG shapes did +not predict different basins. The failure fingerprint replaces it. + +## The signal: per-source pass rates + +The verifier extracts claims from six independent vantage points: + +| Letter | Vantage | +| --- | --- | +| A | Literature / methodology bar | +| B | User goal / deliverable fidelity | +| C | Agent narration truthfulness (recompute-from-disk) | +| D | Math invariants | +| E | Computational reproducibility / CS practice | +| F | Statistical fingerprint | + +Each claim is verified to one of `pass`, `fail`, `error`, `unsure`. + +**Per source**, the fingerprint takes the pass rate: + +``` +pass_rate[s] = (# claims with status == "pass" and source == s) / (# claims with source == s) +``` + +`fail`, `error`, and `unsure` all count as non-passes — a measurement +error is still a non-pass from the optimiser's perspective, and quality +already penalises errors via `quality_norm`. + +## Centering: the quality firewall + +Per source pass rates **alone** would leak quality into novelty (an +all-pass run would sit at `[1,1,1,1,1,1]`, an all-fail one at +`[0,0,0,0,0,0]`, and their Euclidean distance would be large). To strip +quality out, we subtract the mean pass rate across present sources from +every entry: + +``` +mean_present = mean(pass_rate[s] for s in SOURCES if presence_mask[s]) +vector[s] = pass_rate[s] - mean_present if presence_mask[s] + = 0 otherwise +``` + +The descriptor now encodes the *profile shape* of which sources fail +relative to the others — NOT the overall quality level. An all-pass run +and an all-fail run **both** yield the zero profile. This is the +*quality firewall* — and it is asserted in +[`tests/failure_fingerprint_test.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/tests/failure_fingerprint_test.py) +by `test_all_pass_yields_zero_profile` and `test_all_fail_yields_zero_profile`. + +A run that fails source A but passes the others looks *very different* +from a run that fails source D but passes the others. Both might be +mediocre on quality, and both deserve a seat in the archive because each +one explores a different basin of failure modes. + +## Sources of variables + +``` + ┌────────────────────────────────────┐ + workflow run │ VerifierEvaluator.evaluate() │ + ──────────────► │ per_claim = list of: │ + uuid, code, │ {"claim": {"source": "source_X"}, + execution_text │ "status": "pass" | "fail" | ..} + └─────────────┬──────────────────────┘ + │ + ▼ + ┌────────────────────────────────────┐ + │ failure_fingerprint │ + │ .compute_failure_fingerprint │ + │ -> {vector, presence_mask, │ + │ pass_rates} │ + └─────────────┬──────────────────────┘ + │ + │ persisted in scores dict + ▼ + ┌────────────────────────────────────┐ + │ state_result.json │ + │ evaluation.verifier │ + │ .failure_fingerprint │ + │ .vector (list[float], len 6) + │ .presence_mask (list[float], len 6) + │ .pass_rates (list[float], len 6) + └─────────────┬──────────────────────┘ + │ + │ EvolutionEngine reads via + │ WorkflowInfo.state_result + ▼ + ┌────────────────────────────────────┐ + │ IndividualRun.state_result │ + └─────────────┬──────────────────────┘ + │ + ▼ + ┌────────────────────────────────────┐ + │ SelectionPressure │ + │ ._extract_behaviour_descriptor │ + │ → failure_fingerprint_from_ │ + │ state_result(run.state_result) + │ → falls back to │ + │ neutral_fingerprint() when │ + │ verifier hasn't written one │ + └─────────────┬──────────────────────┘ + │ + ▼ + ┌────────────────────────────────────┐ + │ k-NN novelty in 6-D space │ + │ PopulationMember.behaviour_ │ + │ descriptor │ + └────────────────────────────────────┘ +``` + +## Variable inventory (audit table) + +| Symbol | Type | Set by | Read by | Notes | +| --- | --- | --- | --- | --- | +| `per_claim[i]["claim"]["source"]` | str | `verifier_claims._extract_claims` | `compute_failure_fingerprint` | Normalised to `a`..`f` via `_source_letter`. | +| `per_claim[i]["status"]` | str | `verifier_per_claim._verify_claim` | `compute_failure_fingerprint` | One of `pass`/`fail`/`error`/`unsure`. | +| `pass_rates` | `list[float]` (len 6) | `compute_failure_fingerprint` | Persisted; debugging only. | Neutral 0.5 when source absent. | +| `presence_mask` | `list[float]` (len 6) | `compute_failure_fingerprint` | Persisted; debugging only. | `1.0` iff source emitted ≥1 claim. | +| `vector` | `list[float]` (len 6) | `compute_failure_fingerprint` | `_extract_behaviour_descriptor` → k-NN | The centered descriptor; **the QD signal**. | +| `state_result.evaluation.verifier.failure_fingerprint` | dict | `VerifierEvaluator.evaluate` / `_short_circuit_failed_run` | `failure_fingerprint_from_state_result` | Persisted in `state_result.json`. | +| `IndividualRun.state_result` | dict | `EvolutionEngine._evaluate_and_calculate_cost` | `SelectionPressure._extract_behaviour_descriptor` | Carries the fingerprint into selection. | +| `PopulationMember.behaviour_descriptor` | `list[float]` (len 6) | `SelectionPressure._validate_open_ended` | `_compute_novelty` | Snapshot of the fingerprint at admission time. | +| `qd_score` | float | `SelectionPressure._validate_open_ended` and `_refresh_member_metrics` | parent draw, archive eviction | `(1 − w)·quality_norm + w·novelty_norm`; quality and novelty are *additive*, never multiplied. | + +## Failure modes the audit checks + +- **No claims at all** (verifier short-circuit): the run gets the zero + vector and zero presence mask. Distance to peers stays finite; the + cold run isn't pushed to admit or reject solely on novelty. +- **Source F absent in practice** (the current code wires sources A–E): + `presence_mask[5]` stays `0.0`; the centering step ignores that axis; + distance lookups remain well-defined. +- **Unknown source label**: dropped silently by `_source_letter`. No + out-of-band claim can pollute a bucket. +- **Mismatched descriptor length across archive members**: only happens + during a schema upgrade; `_euclidean` returns `+inf` as a sentinel and + `_novelty_range` clamps the normalisation. We recommend draining the + archive when the dim changes — the descriptor dim is now a stable 6. + +## Invariants (asserted) + +- `len(vector) == DESCRIPTOR_DIM == 6`. +- All-pass and all-fail yield `[0]*6` (`test_all_pass_yields_zero_profile`, `test_all_fail_yields_zero_profile`). +- Two runs with the same per-source pass rates yield the **same** vector, + regardless of overall reward — distance is `0`, so the second is treated + as redundant. +- Two runs with opposite shape (a-pass-b-fail vs a-fail-b-pass) yield + vectors that mirror each other through the origin + (`test_distinct_profiles_yield_nonzero_distance`). diff --git a/docs/reference/file-layout.md b/docs/reference/file-layout.md index 4c8f01e..fd488d5 100644 --- a/docs/reference/file-layout.md +++ b/docs/reference/file-layout.md @@ -18,7 +18,8 @@ mimosa-ai/ │ │ ├── selection.py # QD archive + admission gate │ │ ├── variation_engine.py # Mutation / crossover prompt assembly │ │ ├── workflow_selection.py # Parent retrieval (archive / disk) -│ │ ├── code_features.py # AST → behaviour descriptor +│ │ ├── failure_fingerprint.py # Verifier verdicts → QD behaviour descriptor (6-D, centered) +│ │ ├── code_features.py # Legacy structural descriptor (offline analysis only) │ │ ├── lineage.py # Parent → child sidecar records │ │ ├── orchestrator.py # Grounding → factory → sandbox │ │ ├── workflow_factory.py # Multi-agent workflow synthesis diff --git a/sources/core/evaluators/verifier.py b/sources/core/evaluators/verifier.py index f01a46c..ff87732 100644 --- a/sources/core/evaluators/verifier.py +++ b/sources/core/evaluators/verifier.py @@ -33,6 +33,11 @@ print_ok, ) +from sources.core.failure_fingerprint import ( + DESCRIPTOR_DIM as _FP_DIM, + compute_failure_fingerprint, +) + from .base import ( BaseEvaluator, EvaluatorError, @@ -360,6 +365,7 @@ def evaluate( self._print_per_claim_timings(per_claim) scores = self._aggregate(per_claim) + scores["failure_fingerprint"] = compute_failure_fingerprint(per_claim) # Layer 3: independent cheat audit over the agents' produced script. cheat = None # NOTE: cheat_detector was crap. Will need to be rethink. @@ -561,6 +567,14 @@ def _short_circuit_failed_run(self, uuid: str) -> dict[str, Any]: "skipped_reason": "workflow_generation_or_execution_failed", "abstractec_textual_gradient": "workflow code failed to generate or execute; ensure code is properly formatted and that the workflow runs without crashing", "cheat_penalty": 0.0, + # Zero-profile fingerprint: no source emitted any claim, so the + # presence mask is all-zero and the centered vector is all-zero + # — the run is neutral relative to peers along every axis. + "failure_fingerprint": { + "vector": [0.0] * _FP_DIM, + "presence_mask": [0.0] * _FP_DIM, + "pass_rates": [0.5] * _FP_DIM, + }, } try: self._write_report(uuid, [], [], scores, cheat=None) diff --git a/sources/core/failure_fingerprint.py b/sources/core/failure_fingerprint.py new file mode 100644 index 0000000..de1a2cb --- /dev/null +++ b/sources/core/failure_fingerprint.py @@ -0,0 +1,208 @@ +"""Failure-fingerprint behaviour descriptor for the QD archive. + +Replaces the topology-based descriptor for novelty: instead of measuring +"different DAG shapes", we measure "different failure profiles" — two +workflows that fail in the same way are redundant regardless of how +different their structure looks; two that fail in different ways explore +different basins and should both survive. + +Per source A-F, the descriptor uses the per-claim pass rate. Sources with +zero claims get a neutral value of ``0.5`` and presence-mask ``0``. The +final vector is then **centered**: the mean pass rate across present +sources is subtracted from every entry. This encodes the *profile shape* +of which sources fail relative to the others — NOT the overall quality +level. An all-pass run and an all-fail run both yield the zero profile; +quality already drives ``quality_norm`` in QD, and must not leak into +novelty or QD collapses back into greedy search. +""" + +import json +from pathlib import Path +from typing import Any, Iterable + +# Canonical claim-source order. The verifier emits ``"source_a"`` … through +# the six independent extractor prompts; expected letters are kept here so +# the descriptor stays a fixed-length vector even if a source is silenced. +SOURCES: tuple[str, ...] = ("a", "b", "c", "d", "e", "f") +DESCRIPTOR_DIM: int = len(SOURCES) + +_NEUTRAL_PASS_RATE: float = 0.5 +_PASS_STATUS: str = "pass" + + +def _source_letter(raw: Any) -> str | None: + """Normalise a claim source label to its lowercase letter (``a``-``f``). + + Accepts ``"source_a"``, ``"a"``, ``"A"``, ``"SOURCE_A"``. Returns the + bare letter when recognised, ``None`` otherwise. + """ + if not raw: + return None + s = str(raw).strip().lower() + if s.startswith("source_"): + s = s[len("source_"):] + return s if (len(s) == 1 and s in SOURCES) else None + + +def _pass_total_by_source( + per_claim: Iterable[dict[str, Any]], +) -> dict[str, tuple[int, int]]: + """Tally ``(passes, total)`` per canonical source letter. + + Non-pass statuses (``fail``, ``error``, ``unsure``) all count toward + ``total`` but not ``passes``: a measurement error is still a non-pass + from the optimiser's perspective, and quality already penalises errors + via ``quality_norm``. + """ + tally: dict[str, list[int]] = {s: [0, 0] for s in SOURCES} + for c in per_claim: + claim = c.get("claim") if isinstance(c, dict) else None + letter = _source_letter((claim or {}).get("source")) + if letter is None: + continue + tally[letter][1] += 1 + if c.get("status") == _PASS_STATUS: + tally[letter][0] += 1 + return {s: (passes, total) for s, (passes, total) in tally.items()} + + +def compute_failure_fingerprint( + per_claim: Iterable[dict[str, Any]], +) -> dict[str, list[float]]: + """Return centered fingerprint, presence mask, and raw pass rates. + + Args: + per_claim: Iterable of per-claim scored dicts as produced by the + verifier (each carries ``status`` and ``claim["source"]``). + + Returns: + Dict with three fixed-length lists of length ``DESCRIPTOR_DIM``: + ``vector`` (the centered descriptor used as the QD behaviour + descriptor), ``presence_mask`` (1.0 when a source emitted at + least one claim, 0.0 otherwise), and ``pass_rates`` (raw pass + rate per source, with absent sources filled by the neutral 0.5). + """ + counts = _pass_total_by_source(per_claim) + raw: list[float] = [] + presence: list[float] = [] + for s in SOURCES: + passes, total = counts[s] + if total > 0: + raw.append(passes / total) + presence.append(1.0) + else: + raw.append(_NEUTRAL_PASS_RATE) + presence.append(0.0) + + present_values = [r for r, p in zip(raw, presence) if p > 0.5] + mean_present = ( + sum(present_values) / len(present_values) if present_values else 0.0 + ) + vector = [ + (r - mean_present) if p > 0.5 else 0.0 + for r, p in zip(raw, presence) + ] + return { + "vector": vector, + "presence_mask": presence, + "pass_rates": raw, + } + + +def failure_fingerprint_from_state_result( + state_result: dict[str, Any] | None, +) -> list[float] | None: + """Pull the centered vector from a persisted ``state_result`` dict. + + Returns ``None`` when no usable fingerprint has been persisted (the + workflow short-circuited, the verifier hasn't run yet, or the field + has the wrong shape). Callers should fall back to a neutral vector + so distance lookups stay well-defined. + """ + if not isinstance(state_result, dict): + return None + evaluation = state_result.get("evaluation") + if not isinstance(evaluation, dict): + return None + verifier = evaluation.get("verifier") + if not isinstance(verifier, dict): + return None + fp = verifier.get("failure_fingerprint") + if not isinstance(fp, dict): + return None + vec = fp.get("vector") + if not isinstance(vec, list) or len(vec) != DESCRIPTOR_DIM: + return None + try: + return [float(x) for x in vec] + except (TypeError, ValueError): + return None + + +def failure_fingerprint_from_workflow_dir( + workflow_dir: str | Path, + uuid: str, +) -> list[float] | None: + """Read the fingerprint vector from ``//state_result.json``. + + Returns ``None`` when the file is missing, unreadable, or doesn't + carry a fingerprint of the expected shape. + """ + path = Path(workflow_dir) / uuid / "state_result.json" + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return failure_fingerprint_from_state_result(state) + + +def neutral_fingerprint() -> list[float]: + """Zero-vector fingerprint of length ``DESCRIPTOR_DIM``. + + Used as the cold-start default (no archive yet, no verifier output + yet) so distance computations stay well-defined and the cold run is + treated as neither novel nor stale relative to itself. + """ + return [0.0] * DESCRIPTOR_DIM + + +if __name__ == "__main__": + # Quality firewall: all-pass and all-fail must both yield zero profile. + all_pass = [ + {"claim": {"source": "source_a"}, "status": "pass"}, + {"claim": {"source": "source_b"}, "status": "pass"}, + {"claim": {"source": "source_c"}, "status": "pass"}, + ] + all_fail = [ + {"claim": {"source": "source_a"}, "status": "fail"}, + {"claim": {"source": "source_b"}, "status": "fail"}, + {"claim": {"source": "source_c"}, "status": "fail"}, + ] + assert compute_failure_fingerprint(all_pass)["vector"] == [0.0] * DESCRIPTOR_DIM + assert compute_failure_fingerprint(all_fail)["vector"] == [0.0] * DESCRIPTOR_DIM + + # Mixed: a passes, b fails => a positive, b negative, others zero. + mixed = compute_failure_fingerprint([ + {"claim": {"source": "source_a"}, "status": "pass"}, + {"claim": {"source": "source_b"}, "status": "fail"}, + ]) + assert mixed["vector"][0] > 0.0 and mixed["vector"][1] < 0.0 + assert mixed["presence_mask"][:2] == [1.0, 1.0] + assert mixed["presence_mask"][2:] == [0.0] * 4 + assert mixed["vector"][2:] == [0.0] * 4 + + # Source label normalisation across forms. + norm = compute_failure_fingerprint([ + {"claim": {"source": "A"}, "status": "pass"}, + {"claim": {"source": "SOURCE_B"}, "status": "fail"}, + ]) + assert norm["presence_mask"][0] == 1.0 and norm["presence_mask"][1] == 1.0 + + # state_result projection round-trip. + state = {"evaluation": {"verifier": {"failure_fingerprint": mixed}}} + assert failure_fingerprint_from_state_result(state) == mixed["vector"] + assert failure_fingerprint_from_state_result({}) is None + assert failure_fingerprint_from_state_result(None) is None + + assert neutral_fingerprint() == [0.0] * DESCRIPTOR_DIM + print("smoke OK: centered fingerprint, quality firewall holds") diff --git a/sources/core/selection.py b/sources/core/selection.py index 32fe117..f60c546 100644 --- a/sources/core/selection.py +++ b/sources/core/selection.py @@ -13,7 +13,11 @@ from enum import Enum from typing import Any -from .code_features import extract_code_features +from .failure_fingerprint import ( + DESCRIPTOR_DIM as _DESCRIPTOR_DIM, + failure_fingerprint_from_state_result, + neutral_fingerprint, +) MAX_CHILDREN_PER_PARENT = 2 @@ -34,8 +38,9 @@ class PopulationMember: reward: Capped reward used for greedy comparisons. cost: Monetary or compute cost spent to produce this member. uuid: Workflow UUID; ``None`` for members without on-disk artifacts. - behaviour_descriptor: Fixed-length structural feature vector used - for novelty distance. + behaviour_descriptor: Fixed-length failure-fingerprint vector used + for novelty distance — centered per-source pass rates from the + verifier, produced by :mod:`sources.core.failure_fingerprint`. novelty_score: k-NN novelty against the rest of the archive. qd_score: Combined quality-diversity score. reward_uncapped: Base + info-bonus − cheat, with no hard-fail cap. @@ -427,17 +432,28 @@ def _validate_open_ended( return result def _extract_behaviour_descriptor(self, run: Any) -> list[float]: - """Topology-based descriptor parsed from the workflow source. + """Failure-fingerprint descriptor read from the verifier's output. - Reads ``run.code`` and returns a fixed-length vector of structural features. + Per-source pass rates are centered to a quality-free profile by + :func:`failure_fingerprint.compute_failure_fingerprint` and persisted + under ``state_result['evaluation']['verifier']['failure_fingerprint']``. + This method projects that vector out so QD novelty measures *how* + candidates fail relative to each other, not whether they fail. + + When the run has no usable fingerprint (verifier not yet run, + short-circuit on a fully failed workflow) the neutral zero vector + is returned so distance lookups stay well-defined. Args: - run: Object exposing a ``code`` attribute with workflow source. + run: Object exposing a ``state_result`` attribute populated by + the evolution loop after evaluation. Returns: - Fixed-length feature vector used as a behaviour descriptor. + Centered fingerprint of length ``DESCRIPTOR_DIM``. """ - return extract_code_features(_safe_attr(run, "code", None)) + state_result = _safe_attr(run, "state_result", None) + fp = failure_fingerprint_from_state_result(state_result) + return fp if fp is not None else neutral_fingerprint() def _compute_novelty(self, descriptor: list[float]) -> float: """Compute novelty as mean distance to k-nearest archive members. @@ -676,21 +692,42 @@ def _euclidean(a: list[float], b: list[float]) -> float: if __name__ == "__main__": from types import SimpleNamespace - sp = SelectionPressure(strategy="qd", population_size=50, novelty_k_neighbours=25, novelty_weight=0.4) + def _state(vector: list[float]) -> dict[str, Any]: + """Build a state_result stub carrying a centered failure fingerprint.""" + return { + "evaluation": { + "verifier": { + "failure_fingerprint": { + "vector": vector, + "presence_mask": [1.0] * _DESCRIPTOR_DIM, + "pass_rates": [0.5] * _DESCRIPTOR_DIM, + } + } + } + } + + sp = SelectionPressure( + strategy="qd", population_size=50, novelty_k_neighbours=25, novelty_weight=0.4, + ) seed = SimpleNamespace( reward=0.97, reward_uncapped=1.05, current_uuid="seed", - iteration_count=1, cost=0.0, code="x=1", + iteration_count=1, cost=0.0, + state_result=_state([0.3, -0.2, 0.0, 0.0, -0.1, 0.0]), ) sp._validate_open_ended([seed], [seed], threshold=0.01) assert len(sp._archive) == 1, sp._archive + # Different failure profile: D and E refuted instead of A — should land + # despite a lower reward, because its fingerprint is far from the seed's. distinct = SimpleNamespace( reward=0.91, reward_uncapped=0.91, current_uuid="distinct", iteration_count=5, cost=0.0, - code="\n".join(["def f():"] + [" x = 'y' * 800"] * 6), + state_result=_state([-0.2, 0.1, 0.0, -0.4, 0.5, 0.0]), ) sp._validate_open_ended([seed], [distinct], threshold=0.01) - assert len(sp._archive) == 2, f"distinct sibling rejected; archive={[m.uuid for m in sp._archive]}" + assert len(sp._archive) == 2, ( + f"distinct sibling rejected; archive={[m.uuid for m in sp._archive]}" + ) - print("smoke OK: distinct sibling admitted alongside higher-reward seed") \ No newline at end of file + print("smoke OK: distinct failure profile admitted alongside higher-reward seed") \ No newline at end of file diff --git a/tests/failure_fingerprint_test.py b/tests/failure_fingerprint_test.py new file mode 100644 index 0000000..99761f0 --- /dev/null +++ b/tests/failure_fingerprint_test.py @@ -0,0 +1,166 @@ +"""Tests for the failure-fingerprint behaviour descriptor. + +The centering rule is the quality firewall: an all-pass run and an all-fail +run must both yield the zero profile. Anything else lets overall quality +leak into novelty and collapses QD into greedy search. +""" + +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).parent.parent)) + +from sources.core.failure_fingerprint import ( + DESCRIPTOR_DIM, + SOURCES, + compute_failure_fingerprint, + failure_fingerprint_from_state_result, + failure_fingerprint_from_workflow_dir, + neutral_fingerprint, +) + + +def _claim(letter: str, status: str) -> dict: + return {"claim": {"source": f"source_{letter}"}, "status": status} + + +def test_dimension_is_six(): + assert DESCRIPTOR_DIM == 6 + assert SOURCES == ("a", "b", "c", "d", "e", "f") + + +def test_all_pass_yields_zero_profile(): + """Quality firewall: an all-pass run must be neutral in profile space.""" + per_claim = [_claim(s, "pass") for s in SOURCES] + out = compute_failure_fingerprint(per_claim) + assert out["vector"] == [0.0] * DESCRIPTOR_DIM + assert out["presence_mask"] == [1.0] * DESCRIPTOR_DIM + assert out["pass_rates"] == [1.0] * DESCRIPTOR_DIM + + +def test_all_fail_yields_zero_profile(): + """Quality firewall: an all-fail run must also be neutral in profile space.""" + per_claim = [_claim(s, "fail") for s in SOURCES] + out = compute_failure_fingerprint(per_claim) + assert out["vector"] == [0.0] * DESCRIPTOR_DIM + assert out["presence_mask"] == [1.0] * DESCRIPTOR_DIM + assert out["pass_rates"] == [0.0] * DESCRIPTOR_DIM + + +def test_distinct_profiles_yield_nonzero_distance(): + """Two runs at the same overall quality but different shapes must differ.""" + a_passes_b_fails = compute_failure_fingerprint([ + _claim("a", "pass"), + _claim("b", "fail"), + ])["vector"] + a_fails_b_passes = compute_failure_fingerprint([ + _claim("a", "fail"), + _claim("b", "pass"), + ])["vector"] + assert a_passes_b_fails != a_fails_b_passes + # Same overall quality (50% pass), so identical means; opposite shape. + assert a_passes_b_fails[0] == -a_fails_b_passes[0] + assert a_passes_b_fails[1] == -a_fails_b_passes[1] + + +def test_absent_source_uses_neutral_and_mask(): + """A source with zero claims must not influence the centered mean.""" + out = compute_failure_fingerprint([ + _claim("a", "pass"), + _claim("b", "pass"), + _claim("c", "fail"), + ]) + assert out["presence_mask"] == [1.0, 1.0, 1.0, 0.0, 0.0, 0.0] + # Mean over present sources only: (1+1+0)/3 = 0.667. + assert out["pass_rates"][:3] == [1.0, 1.0, 0.0] + assert out["pass_rates"][3:] == [0.5, 0.5, 0.5] + # Centered: present entries minus 0.667; absent entries forced to zero. + assert all(abs(out["vector"][i]) > 1e-6 for i in range(3)) + assert out["vector"][3:] == [0.0, 0.0, 0.0] + + +def test_non_pass_statuses_count_as_failures(): + """``fail``, ``error``, and ``unsure`` all reduce the per-source pass rate.""" + out = compute_failure_fingerprint([ + _claim("a", "pass"), + _claim("a", "fail"), + _claim("a", "error"), + _claim("a", "unsure"), + ]) + assert abs(out["pass_rates"][0] - 0.25) < 1e-9 + + +def test_source_label_normalisation(): + """Accepts ``"a"``, ``"A"``, ``"source_a"``, ``"SOURCE_A"`` interchangeably.""" + variants = [ + {"claim": {"source": "a"}, "status": "pass"}, + {"claim": {"source": "B"}, "status": "fail"}, + {"claim": {"source": "source_c"}, "status": "pass"}, + {"claim": {"source": "SOURCE_D"}, "status": "fail"}, + ] + out = compute_failure_fingerprint(variants) + assert out["presence_mask"] == [1.0, 1.0, 1.0, 1.0, 0.0, 0.0] + + +def test_unknown_source_is_ignored(): + """Claims tagged with an unknown source must not poison any bucket.""" + out = compute_failure_fingerprint([ + _claim("a", "pass"), + {"claim": {"source": "source_z"}, "status": "fail"}, + {"claim": {"source": None}, "status": "pass"}, + ]) + assert out["presence_mask"] == [1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + + +def test_empty_per_claim_yields_neutral_vector(): + """No claims at all → all-zero vector, all-zero mask, neutral 0.5 rates.""" + out = compute_failure_fingerprint([]) + assert out["vector"] == [0.0] * DESCRIPTOR_DIM + assert out["presence_mask"] == [0.0] * DESCRIPTOR_DIM + assert out["pass_rates"] == [0.5] * DESCRIPTOR_DIM + + +def test_state_result_projection(): + """``failure_fingerprint_from_state_result`` returns the centered vector.""" + fp = compute_failure_fingerprint([_claim("a", "pass"), _claim("b", "fail")]) + state = {"evaluation": {"verifier": {"failure_fingerprint": fp}}} + assert failure_fingerprint_from_state_result(state) == fp["vector"] + + +def test_state_result_projection_missing_returns_none(): + assert failure_fingerprint_from_state_result(None) is None + assert failure_fingerprint_from_state_result({}) is None + assert failure_fingerprint_from_state_result({"evaluation": {}}) is None + assert failure_fingerprint_from_state_result( + {"evaluation": {"verifier": {"failure_fingerprint": {"vector": [0.0, 0.0]}}}} + ) is None + + +def test_workflow_dir_round_trip(tmp_path_factory=None): + """Round-trip via on-disk ``state_result.json``.""" + import json + import tempfile + + fp = compute_failure_fingerprint([_claim("a", "pass"), _claim("b", "fail")]) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + uuid = "uuid123" + (root / uuid).mkdir() + (root / uuid / "state_result.json").write_text( + json.dumps({"evaluation": {"verifier": {"failure_fingerprint": fp}}}), + encoding="utf-8", + ) + assert failure_fingerprint_from_workflow_dir(root, uuid) == fp["vector"] + assert failure_fingerprint_from_workflow_dir(root, "missing") is None + + +def test_neutral_fingerprint_helper(): + assert neutral_fingerprint() == [0.0] * DESCRIPTOR_DIM + + +if __name__ == "__main__": + for name, fn in list(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print(f" ✓ {name}") + print("All failure_fingerprint_test passed.") diff --git a/tests/selection_test.py b/tests/selection_test.py index daab948..54d5ae1 100644 --- a/tests/selection_test.py +++ b/tests/selection_test.py @@ -6,38 +6,70 @@ sys.path.append(str(Path(__file__).parent.parent)) +from sources.core.failure_fingerprint import DESCRIPTOR_DIM from sources.core.selection import PopulationMember, SelectionPressure -def _run(code: str = "x = 1", reward: float = 0.9, uuid: str = "u") -> SimpleNamespace: +def _fp_state(vector: list[float]) -> dict: + """Build a state_result stub exposing a centered failure fingerprint.""" + assert len(vector) == DESCRIPTOR_DIM + return { + "evaluation": { + "verifier": { + "failure_fingerprint": { + "vector": vector, + "presence_mask": [1.0] * DESCRIPTOR_DIM, + "pass_rates": [0.5] * DESCRIPTOR_DIM, + } + } + } + } + + +def _run( + vector: list[float] | None = None, + reward: float = 0.9, + uuid: str = "u", +) -> SimpleNamespace: return SimpleNamespace( reward=reward, reward_uncapped=reward, current_uuid=uuid, iteration_count=1, cost=0.0, - code=code, + state_result=_fp_state(vector if vector is not None else [0.0] * DESCRIPTOR_DIM), ) def test_first_run_admitted(): sp = SelectionPressure(strategy="qd", population_size=50) - sp._validate_open_ended([_run(reward=0.0)], [_run(reward=0.97, uuid="seed")], threshold=0.01) + sp._validate_open_ended( + [_run(reward=0.0)], + [_run(reward=0.97, uuid="seed", vector=[0.3, -0.2, 0.0, 0.0, -0.1, 0.0])], + threshold=0.01, + ) assert [m.uuid for m in sp._archive] == ["seed"] -def test_distinct_lower_reward_sibling_is_admitted(): - """Regression case from the observed ClinTox run: c83cfabb (score 0.91, descriptor - distance 2.69 from the seed at 0.97) used to be Pareto-rejected. It should now land.""" +def test_distinct_failure_profile_sibling_is_admitted(): + """Regression case under the new fingerprint descriptor. + + Mirror of the ClinTox c83cfabb scenario, restated in fingerprint space: + a lower-reward sibling whose failure profile fails on different sources + must land — quality-only selection would Pareto-reject it. + """ sp = SelectionPressure(strategy="qd", population_size=50, novelty_k_neighbours=25) seed = PopulationMember( - iteration=1, reward=0.97, cost=0.0, uuid="seed", - behaviour_descriptor=[0.3, 0.4, 0.0, 1.933], reward_uncapped=1.05, + iteration=1, reward=0.97, cost=0.0, uuid="seed", reward_uncapped=1.05, + behaviour_descriptor=[0.3, -0.2, 0.0, 0.0, -0.1, 0.0], ) sp._archive = [seed] - distinct = _run(reward=0.91, uuid="distinct") - distinct.code = "\n".join(["def f():"] + [" s = 'y' * 800"] * 6) + distinct = _run( + reward=0.91, + uuid="distinct", + vector=[-0.2, 0.1, 0.0, -0.4, 0.5, 0.0], + ) sp._validate_open_ended([seed], [distinct], threshold=0.01) assert {m.uuid for m in sp._archive} == {"seed", "distinct"} From 9d7c9044f188d4da80770dad6996efdd3e9c78dd Mon Sep 17 00:00:00 2001 From: martin Date: Thu, 11 Jun 2026 17:29:46 +0200 Subject: [PATCH 07/71] fix (smolagent_module): parse_memory_output empty string --- sources/modules/smolagent_factory.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/sources/modules/smolagent_factory.py b/sources/modules/smolagent_factory.py index d110753..70f479d 100644 --- a/sources/modules/smolagent_factory.py +++ b/sources/modules/smolagent_factory.py @@ -215,17 +215,25 @@ def build_workflow_step_prompt(self, state: WorkflowState) -> str: def parse_memory_output(self): actions, observations, success = [], [], [] for step in self.agent.memory.steps: - if isinstance(step, ActionStep): - error, obs = step.error, step.observations + if not isinstance(step, ActionStep): + continue + + step_action = getattr(step, "code_action", "") or "" + if not step_action and getattr(step, "tool_calls", None): + tc = step.tool_calls[0] + args = getattr(getattr(tc, "function", tc), "arguments", "") + step_action = args if isinstance(args, str) else json.dumps(args) + + if isinstance(step.observations, str) and step.observations: + step_obs = step.observations + elif step.error is not None: + step_obs = str(step.error) + else: step_obs = "" - step_action = "" - feedback = obs if obs else error - if type(feedback) is not str: - step_obs = feedback.dict()["message"] if "message" in feedback.dict() else "" - step_action = feedback.dict()["code_action"] if "code_action" in feedback.dict() else "" - actions.append(step_action) - observations.append(step_obs) - success.append(step.error is None) + + actions.append(step_action) + observations.append(step_obs) + success.append(step.error is None) return actions, observations, success def save_memories(self, workflow_uuid: str): From 559f534ae12156834281635252aed21259a48cdb Mon Sep 17 00:00:00 2001 From: martin Date: Fri, 12 Jun 2026 10:17:40 +0200 Subject: [PATCH 08/71] fix(onboard_cli): robust LLM JSON parsing + gibberish-tolerant prompts Co-Authored-By: Claude Opus 4.7 (1M context) --- sources/cli/onboard_cli.py | 420 +++++++++++++++++++++++++++++++------ 1 file changed, 351 insertions(+), 69 deletions(-) diff --git a/sources/cli/onboard_cli.py b/sources/cli/onboard_cli.py index 93edf95..148e14c 100644 --- a/sources/cli/onboard_cli.py +++ b/sources/cli/onboard_cli.py @@ -12,6 +12,7 @@ import asyncio import json +import re import time import os import sys @@ -154,15 +155,169 @@ def _call_llm(llm: LLMProvider, system: str, user: str) -> str: return llm(user, use_cache=False) -def _parse_json_response(raw: str) -> dict: - """Strip markdown fences and parse JSON.""" - raw = raw.strip() - if raw.startswith("```"): - raw = "\n".join( - line for line in raw.splitlines() +_TRAILING_COMMA_RE = re.compile(r",\s*([}\]])") + + +def _strip_md_fences(text: str) -> str: + text = text.strip() + if text.startswith("```"): + text = "\n".join( + line for line in text.splitlines() if not line.strip().startswith("```") ).strip() - return json.loads(raw) + return text + + +def _repair_json(text: str) -> str: + """Apply small lossless repairs to make malformed LLM JSON parseable. + + Currently: drop trailing commas before `}` or `]`. We deliberately do NOT + try to escape interior quotes here — that requires real parsing and the + regex fallback in `_parse_json_response` handles that case instead. + """ + return _TRAILING_COMMA_RE.sub(r"\1", text) + + +def _extract_string_field(text: str, key: str) -> str | None: + """Lenient extraction of a JSON string field, tolerating unescaped quotes. + + Uses a lazy match anchored on the next key (`"key": `) or the closing `}`, + so interior `"` characters are preserved instead of prematurely closing + the string. + """ + pattern = ( + rf'"{re.escape(key)}"\s*:\s*"(.*?)"\s*' + rf'(?=,\s*"[A-Za-z_][A-Za-z0-9_]*"\s*:|\}})' + ) + m = re.search(pattern, text, flags=re.DOTALL) + return m.group(1) if m else None + + +def _extract_bool_field(text: str, key: str) -> bool | None: + m = re.search( + rf'"{re.escape(key)}"\s*:\s*(true|false)\b', + text, + flags=re.IGNORECASE, + ) + if not m: + return None + return m.group(1).lower() == "true" + + +def _extract_number_field(text: str, key: str) -> float | None: + m = re.search(rf'"{re.escape(key)}"\s*:\s*(-?\d+(?:\.\d+)?)', text) + return float(m.group(1)) if m else None + + +def _extract_known_keys(text: str, expected_keys: dict[str, str]) -> dict | None: + """Pull out a known set of top-level keys via regex. + + Last-ditch fallback for when ``json.loads`` cannot parse the LLM response + (typically because of unescaped quotes deep inside a long string value). + + Args: + text: Raw JSON-ish text. + expected_keys: Mapping of ``key -> "str" | "bool" | "number"``. + + Returns: + Dict with whatever keys were successfully extracted, or None if nothing + could be pulled out. + """ + out: dict = {} + for key, kind in expected_keys.items(): + if kind == "str": + val = _extract_string_field(text, key) + elif kind == "bool": + val = _extract_bool_field(text, key) + elif kind == "number": + val = _extract_number_field(text, key) + else: + val = None + if val is not None: + out[key] = val + return out or None + + +def _parse_json_response( + raw: str, + expected_keys: dict[str, str] | None = None, +) -> dict: + """Robustly parse JSON from an LLM response. + + Stages: + 1. Strip markdown fences. + 2. ``json.loads`` directly. + 3. Substring between the first '{' and last '}', then ``json.loads``. + 4. Light repair (trailing-comma removal), then ``json.loads``. + 5. Regex extraction of known keys (only when ``expected_keys`` provided). + + The regex stage is what saves us from unescaped quotes inside long string + values — the classic OpenRouter-quantized-provider failure mode. + """ + text = _strip_md_fences(raw) + if not text: + raise ValueError("Empty LLM response.") + + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + start, end = text.find("{"), text.rfind("}") + snippet = text[start : end + 1] if 0 <= start < end else text + + try: + return json.loads(snippet) + except json.JSONDecodeError: + pass + + repaired = _repair_json(snippet) + last_err: Exception + try: + return json.loads(repaired) + except json.JSONDecodeError as exc: + last_err = exc + + if expected_keys: + extracted = _extract_known_keys(snippet, expected_keys) + if extracted: + return extracted + + raise ValueError(f"Could not parse JSON ({last_err}).") + + +def _call_llm_json( + llm: LLMProvider, + system: str, + user: str, + expected_keys: dict[str, str] | None = None, + retries: int = 2, +) -> dict: + """Call the LLM expecting JSON; retry with self-correction on parse failure. + + On each parse failure the user prompt is augmented with the parser error + plus an explicit reminder to escape interior quotes, newlines and + backslashes. This recovers the common case where the LLM emitted nearly + valid JSON but missed an escape. + """ + last_err: Exception | None = None + current_user = user + for _ in range(retries + 1): + raw = _call_llm(llm, system, current_user) + try: + return _parse_json_response(raw, expected_keys=expected_keys) + except (ValueError, json.JSONDecodeError) as exc: + last_err = exc + current_user = ( + f"{user}\n\n" + f"IMPORTANT: your previous response could not be parsed as JSON " + f"(parser error: {exc}). Return ONLY a single valid JSON object " + f"— no prose, no markdown fences. Escape every interior " + f'double-quote as \\", every newline as \\n, and every ' + f"backslash as \\\\." + ) + assert last_err is not None + raise last_err # --------------------------------------------------------------------------- @@ -181,14 +336,21 @@ def _parse_json_response(raw: str) -> dict: can act on directly (include dataset names, metrics, file paths, or any specifics \ already mentioned). -Return ONLY valid JSON (no markdown fences) in this exact shape: +Return ONLY valid JSON (no markdown fences, no prose before or after) in this exact shape: { "is_clear": true | false, "question": "", "refined_prompt": "" } -Rules: +JSON formatting rules (CRITICAL — failure to follow these breaks downstream parsing): +- Escape EVERY interior double-quote as \\". e.g. write She said \\"hi\\" not She said "hi". +- Escape every newline inside a string as \\n. +- Escape every backslash as \\\\. +- No trailing commas, no comments, no markdown fences. +- The entire response must be a single JSON object — nothing else. + +Content rules: - Ask at most ONE question per turn. - Only mark is_clear=true when you have enough detail to write a rich refined_prompt. - The refined_prompt must incorporate ALL context provided so far. @@ -209,13 +371,18 @@ def _parse_json_response(raw: str) -> dict: building an end-to-end ML pipeline from scratch, running a complete bioinformatics analysis across several steps. -Return ONLY valid JSON (no markdown fences) like: +Return ONLY valid JSON (no markdown fences, no prose) in this exact shape: { "mode": "task" | "goal", "confidence": 0.0-1.0, "reasoning": "", - "suggested_label": "" + "suggested_label": "" } + +JSON formatting rules: +- Escape interior quotes as \\", newlines as \\n, backslashes as \\\\. +- No trailing commas, no comments, no fences. +- The entire response must be a single JSON object — nothing else. """ @@ -530,18 +697,33 @@ def _setup_workspace_files(self) -> None: print(f" {CYAN}none{RESET} – {RED}delete all{RESET} files in the workspace") print() - choice = _ask("Files to keep").strip().lower() + while True: + choice = _ask("Files to keep").strip().lower() + + if choice == "all": + kept_files = list(file_list) + _ok(f"Keeping all {len(kept_files)} file(s).") + break + if choice == "none": + # Delete everything (explicit user choice) + kept_files = [] + if not _ask_yn( + "Confirm: delete ALL files in the workspace?", + default=False, + ): + _info("Aborted — please choose again.") + continue + break + if choice == "": + _warn("Empty input — please type numbers, 'all', or 'none'.") + continue - if choice == "all": - kept_files = list(file_list) - _ok(f"Keeping all {len(kept_files)} file(s).") - elif choice == "none" or choice == "": - # Delete everything - kept_files = [] - else: # Parse comma-separated indices selected_indices: set[int] = set() + had_bad_token = False for part in choice.replace(" ", "").split(","): + if not part: + continue # Support ranges like "1-5" if "-" in part: bounds = part.split("-", 1) @@ -549,21 +731,36 @@ def _setup_workspace_files(self) -> None: lo, hi = int(bounds[0]), int(bounds[1]) selected_indices.update(range(lo, hi + 1)) except ValueError: - _warn(f"Ignoring invalid range: {part}") + _warn(f"Invalid range: {part}") + had_bad_token = True else: try: selected_indices.add(int(part)) except ValueError: - _warn(f"Ignoring invalid number: {part}") + _warn(f"Invalid number: {part}") + had_bad_token = True - for idx in sorted(selected_indices): - if 1 <= idx <= len(file_list): - kept_files.append(file_list[idx - 1]) + kept_files = [ + file_list[idx - 1] + for idx in sorted(selected_indices) + if 1 <= idx <= len(file_list) + ] if kept_files: _ok(f"Keeping {len(kept_files)} file(s).") + break + # No valid indices — re-ask instead of silently deleting all. + if had_bad_token: + _warn( + "No valid file numbers recognised. " + "Please type indices like '1,3,5' or '1-5', " + "or 'all' / 'none'." + ) else: - _warn("No valid files selected — all files will be deleted.") + _warn( + "No files selected. Type 'none' explicitly if you " + "want to delete everything." + ) # ── Perform deletion of un-kept files ───────────────────── if kept_files and len(kept_files) < len(file_list): @@ -697,31 +894,45 @@ def _model_menu( _warn("No matching API key found — enter a model ID manually.") print() - if suggested: - choice = _ask( - "Select number, 'c' for custom, or Enter to keep current", - default="", - ) - else: - choice = _ask("Select number or 'c' for custom") - - if not choice and suggested: - return suggested - if choice.lower() == "c" or (not available): - custom = _ask( - "Enter model ID (e.g. openai/gpt-4o, " - "anthropic/claude-3-5-sonnet-20241022)" - ) - return custom.strip() if custom.strip() else suggested - # Numbered selection - try: - idx = int(choice) - 1 - if 0 <= idx < len(available): - return available[idx][1] - _warn(f"Invalid selection '{choice}'. Using default.") - except ValueError: - _warn(f"Unrecognised input '{choice}'. Using default.") - return suggested or (available[0][1] if available else "") + while True: + if suggested: + choice = _ask( + "Select number, 'c' for custom, or Enter to keep current", + default="", + ) + else: + choice = _ask("Select number or 'c' for custom") + + if not choice and suggested: + return suggested + if choice.lower() == "c" or (not available): + custom = _ask( + "Enter model ID (e.g. openai/gpt-4o, " + "anthropic/claude-3-5-sonnet-20241022)" + ).strip() + if custom: + return custom + if suggested: + _warn("No model ID entered — keeping current.") + return suggested + _warn("No model ID entered — please try again.") + continue + # Numbered selection + try: + idx = int(choice) - 1 + if 0 <= idx < len(available): + return available[idx][1] + _warn( + f"Number out of range: {choice}. " + f"Please pick 1-{len(available)} or 'c'." + ) + continue + except ValueError: + _warn( + f"Unrecognised input '{choice}'. " + "Please pick a number, 'c', or press Enter to keep current." + ) + continue def _choose_models(self) -> None: """Step 3 – model selection. @@ -829,7 +1040,14 @@ def _clarify_and_refine(self) -> None: width=70, indent=2, )) - llm = _build_llm(self.config, temperature=0.3, max_tokens=768) + # Lower temperature → more reliable JSON; bigger token budget so long + # refined_prompts don't get truncated mid-string. + llm = _build_llm(self.config, temperature=0.1, max_tokens=1024) + expected_keys = { + "is_clear": "bool", + "question": "str", + "refined_prompt": "str", + } # Accumulate context: original objective + Q&A pairs context_lines: list[str] = [f"Objective: {self._objective}"] @@ -841,21 +1059,32 @@ def _clarify_and_refine(self) -> None: print(f"\n{DIM} [Clarification round {round_num + 1}/{max_clarification_rounds}]{RESET}") try: - raw = _call_llm(llm, _CLARIFIER_SYSTEM, full_context) - result = _parse_json_response(raw) + result = _call_llm_json( + llm, + _CLARIFIER_SYSTEM, + full_context, + expected_keys=expected_keys, + ) except Exception as exc: - _warn(f"LLM clarification failed ({exc}). Skipping refinement.") + _warn( + f"LLM clarification failed after retries ({exc}). " + "Falling back to manual refinement." + ) + self._manual_refinement_fallback() return - is_clear = result.get("is_clear", False) - question = result.get("question", "").strip() - refined_prompt = result.get("refined_prompt", "").strip() + is_clear = bool(result.get("is_clear", False)) + question = str(result.get("question", "")).strip() + refined_prompt = str(result.get("refined_prompt", "")).strip() if not is_clear and question: # Ask the clarifying question print() print(f" {BOLD}Assistant:{RESET} {question}") - answer = _ask("Your answer") + answer = _ask("Your answer (or 'skip' to stop clarifying)") + if answer.lower() in ("skip", "stop", "done"): + _info("Stopping clarification — using current objective.") + return if answer: context_lines.append(f"Q: {question}") context_lines.append(f"A: {answer}") @@ -888,12 +1117,35 @@ def _clarify_and_refine(self) -> None: _ok(f"Continuing with: {self._objective[:80]}") return + # Neither a usable question nor a refined prompt — nudge the LLM + # and retry within the same loop round budget. + _info("LLM returned an incomplete response — retrying.") + context_lines.append( + "(Reminder: respond with valid JSON containing either a " + "clarifying question OR a refined_prompt — never both empty.)" + ) + # Exhausted rounds without clarity — keep whatever we have _warn( f"Clarification loop completed ({max_clarification_rounds} rounds). " "Using current objective as-is." ) + def _manual_refinement_fallback(self) -> None: + """Offer the user a way to refine the objective by hand when the LLM + clarification round cannot recover. + """ + print() + _info("You can refine your objective manually below.") + edited = _ask( + "Edit your objective (press Enter to keep it as-is)" + ) + if edited: + self._objective = edited.strip() + _ok(f"Objective updated: {self._objective[:80]}") + else: + _info("Keeping objective as-is.") + def _classify_and_confirm(self) -> None: """Use LLM to classify objective as goal or task, confirm with user.""" print(_wrap( @@ -903,23 +1155,46 @@ def _classify_and_confirm(self) -> None: )) classification: dict | None = None - llm = _build_llm(self.config, temperature=0.0, max_tokens=256) + llm = _build_llm(self.config, temperature=0.0, max_tokens=384) + expected_keys = { + "mode": "str", + "confidence": "number", + "reasoning": "str", + "suggested_label": "str", + } try: - raw = _call_llm( + classification = _call_llm_json( llm, _CLASSIFIER_SYSTEM, f"Classify this research objective:\n\n{self._objective}", + expected_keys=expected_keys, ) - classification = _parse_json_response(raw) except Exception as exc: - _warn(f"LLM classification failed ({exc}). Falling back to manual selection.") + _warn( + f"LLM classification failed after retries ({exc}). " + "Falling back to manual selection." + ) + + if classification: + mode_raw = str(classification.get("mode", "")).lower().strip() + if mode_raw not in ("task", "goal"): + _warn( + f"LLM returned an unexpected mode '{mode_raw}'. " + "Falling back to manual selection." + ) + classification = None if classification: - mode = classification.get("mode", "task") - confidence = float(classification.get("confidence", 0.0)) - reasoning = classification.get("reasoning", "") - label = classification.get("suggested_label", self._objective[:40]) + mode = mode_raw # validated above + try: + confidence = float(classification.get("confidence", 0.0)) + except (TypeError, ValueError): + confidence = 0.0 + reasoning = str(classification.get("reasoning", "")) + label = str( + classification.get("suggested_label", self._objective[:40]) + ) print() print(f" {BOLD}Suggested mode:{RESET} {CYAN}{mode.upper()}{RESET} " @@ -939,13 +1214,20 @@ def _classify_and_confirm(self) -> None: self._mode = mode # type: ignore[assignment] return - # Manual fallback / override + # Manual fallback / override — loop until the user picks a valid mode. print() print(f" {BOLD}Available modes:{RESET}") print(f" {CYAN}goal{RESET} – high-level research objective (planner + evolution engine)") print(f" {CYAN}task{RESET} – single focused operation (evolution engine only)") - choice = _ask("Choose mode", default="task").lower() - self._mode = "goal" if choice.startswith("g") else "task" + while True: + choice = _ask("Choose mode (goal/task)", default="task").lower().strip() + if choice in ("goal", "g"): + self._mode = "goal" + break + if choice in ("task", "t"): + self._mode = "task" + break + _warn(f"Unrecognised choice '{choice}'. Please type 'goal' or 'task'.") _ok(f"Mode set to: {self._mode.upper()}") def _collect_options(self) -> None: From 39ad2cdf1959d91bb47ce7a03665c975f53f2150 Mon Sep 17 00:00:00 2001 From: martin Date: Fri, 12 Jun 2026 10:26:40 +0200 Subject: [PATCH 09/71] fix(cli): harden evaluation_cli and memory_chat_cli error handling Co-Authored-By: Claude Opus 4.7 (1M context) --- sources/cli/evaluation_cli.py | 147 +++++++++++++++++++++++---------- sources/cli/memory_chat_cli.py | 136 ++++++++++++++++++++++++++---- 2 files changed, 220 insertions(+), 63 deletions(-) diff --git a/sources/cli/evaluation_cli.py b/sources/cli/evaluation_cli.py index f3561d2..b85e719 100644 --- a/sources/cli/evaluation_cli.py +++ b/sources/cli/evaluation_cli.py @@ -244,27 +244,44 @@ def _choose_agent_model(self, run_config: Config) -> None: else: _warn("No matching API key found – enter a model ID manually.") - choice = _ask( - "Select number, 'c' for custom, or Enter to keep current", - default="", - ) + while True: + choice = _ask( + "Select number, 'c' for custom, or Enter to keep current", + default="", + ) - if not choice and suggested: - model = suggested - elif choice.lower() == "c" or not available: - custom = _ask("Enter model ID (e.g. anthropic/claude-sonnet-4-5)") - model = custom.strip() if custom.strip() else suggested - else: + if not choice and suggested: + model = suggested + break + if choice.lower() == "c" or not available: + custom = _ask( + "Enter model ID (e.g. anthropic/claude-sonnet-4-5)" + ).strip() + if custom: + model = custom + break + if suggested: + _warn("No model ID entered — keeping current.") + model = suggested + break + _warn("No model ID entered — please try again.") + continue try: idx = int(choice) - 1 if 0 <= idx < len(available): model = available[idx][1] - else: - _warn(f"Invalid selection '{choice}'. Using default.") - model = suggested + break + _warn( + f"Number out of range: {choice}. " + f"Please pick 1-{len(available)} or 'c'." + ) + continue except ValueError: - _warn(f"Unrecognised input '{choice}'. Using default.") - model = suggested + _warn( + f"Unrecognised input '{choice}'. " + "Please pick a number, 'c', or press Enter to keep current." + ) + continue run_config.smolagent_model_id = model _ok(f"Agent model: {model}") @@ -415,19 +432,40 @@ async def _setup_connectivity(self, run_config: Config, run_id: int) -> list[str # ---- Discover MCPs --------------------------------------------- tool_manager = ToolManager(config=run_config) mcp_list: list[str] = [] - try: - mcps = await tool_manager.discover_mcp_servers() - except Exception as exc: - _warn(f"Discovery error: {exc}") - mcps = [] - - if mcps: - tool_manager.mcps = mcps - mcp_list = [str(m) for m in mcps] - for m in mcps: - _ok(f"MCP online: {m}") - else: - _err("No MCP servers found. Evaluation may fail at runtime.") + while True: + try: + mcps = await tool_manager.discover_mcp_servers() + except Exception as exc: + _warn(f"Discovery error: {exc}") + mcps = [] + + if mcps: + tool_manager.mcps = mcps + mcp_list = [str(m) for m in mcps] + for m in mcps: + _ok(f"MCP online: {m}") + break + + _err("No MCP servers found.") + addrs = run_config.discovery_addresses + addr_str = ", ".join( + f"{a.ip}:{a.port_min}-{a.port_max}" for a in addrs + ) + print(_wrap( + f"Please start Toolomics on the configured port range ({addr_str}).", + width=70, indent=2, + )) + print(f"\n {BOLD}Options:{RESET}") + print(f" {CYAN}Enter{RESET} – retry scan") + print(f" {CYAN}skip{RESET} – queue this run anyway " + "(will fail at launch)") + choice = _ask("Retry or skip?").strip().lower() + if choice == "skip": + _warn( + "Queuing run with no MCPs detected. " + "Evaluation may fail at runtime." + ) + break # ---- Workspace ------------------------------------------------- suggested_ws = self._suggest_workspace(run_config, run_id) @@ -494,16 +532,21 @@ def _choose_eval_mode(self) -> str: print(f" {CYAN}[3]{RESET} Iterative learning – multi-agent with evolution loop") print() - choice = _ask("Select mode", default="2") - if choice == "1": - _ok("Mode: Single-agent") - return "single_agent" - elif choice == "3": - _ok("Mode: Iterative learning") - return "iterative" - else: - _ok("Mode: One-shot (no learning)") - return "one_shot" + while True: + choice = _ask("Select mode (1/2/3)", default="2").strip().lower() + if choice in ("1", "single", "single_agent", "single-agent"): + _ok("Mode: Single-agent") + return "single_agent" + if choice in ("2", "one_shot", "one-shot", "oneshot"): + _ok("Mode: One-shot (no learning)") + return "one_shot" + if choice in ("3", "iterative", "learning"): + _ok("Mode: Iterative learning") + return "iterative" + _warn( + f"Unrecognised choice '{choice}'. " + "Please enter 1, 2, or 3 (or the mode name)." + ) # ------------------------------------------------------------------ # Step 5 – csv_runs_limit @@ -620,14 +663,18 @@ def _print_queue_summary(self) -> None: # ------------------------------------------------------------------ def _save_start_notes(self, spec: EvalRunSpec) -> None: - """Write initial run metadata to run_notes/evaluations/.""" - eval_dir = Path("run_notes") / "evaluations" - eval_dir.mkdir(parents=True, exist_ok=True) + """Write initial run metadata to run_notes/evaluations/. + Best-effort: if note creation fails (read-only fs, permissions, disk + full, …) we warn and clear ``spec.notes_path`` so subsequent + ``_update_notes`` calls are skipped — execution should not abort just + because we cannot save run notes. + """ + eval_dir = Path("run_notes") / "evaluations" ts = datetime.now().strftime("%Y%m%d_%H%M%S") model_tag = spec.config.smolagent_model_id.replace("/", "_") filename = f"{ts}_run{spec.run_id}_{model_tag}_{spec.eval_mode}.json" - spec.notes_path = eval_dir / filename + notes_path = eval_dir / filename notes = { "started_at": datetime.now().isoformat(), @@ -648,10 +695,20 @@ def _save_start_notes(self, spec: EvalRunSpec) -> None: "queue_size": len(self._queue), } - with open(spec.notes_path, "w", encoding="utf-8") as fh: - json.dump(notes, fh, indent=2) - fh.write("\n") + try: + eval_dir.mkdir(parents=True, exist_ok=True) + with open(notes_path, "w", encoding="utf-8") as fh: + json.dump(notes, fh, indent=2) + fh.write("\n") + except OSError as exc: + _warn( + f"Could not write notes for Run #{spec.run_id} " + f"({notes_path}): {exc}. Continuing without note tracking." + ) + spec.notes_path = None + return + spec.notes_path = notes_path _ok(f"Run #{spec.run_id} notes → {spec.notes_path}") @staticmethod diff --git a/sources/cli/memory_chat_cli.py b/sources/cli/memory_chat_cli.py index 9e729c4..a4c8b4c 100644 --- a/sources/cli/memory_chat_cli.py +++ b/sources/cli/memory_chat_cli.py @@ -178,20 +178,51 @@ def load_memory_chunks(memory_dir: Path) -> list[MemoryChunk]: return chunks +def _available_runs(base: Path) -> list[str]: + """Return run-directory names under *base* that contain memory JSONs, + sorted newest-first by mtime. + """ + try: + candidates = [ + p for p in base.iterdir() + if p.is_dir() and any(p.glob("*.json")) + ] + except OSError: + return [] + return [p.name for p in sorted( + candidates, key=lambda p: p.stat().st_mtime, reverse=True, + )] + + def resolve_run_dir(config: Config, run_uuid: str | None) -> Path: """Return the memory directory for *run_uuid* or the latest run if None.""" base = Path(config.memory_dir) if not base.is_dir(): - raise FileNotFoundError(f"Memory base directory missing: {base}") + raise FileNotFoundError( + f"Memory base directory missing: {base}\n" + "Run Mimosa at least once to populate sources/memory//." + ) if run_uuid: target = base / run_uuid if not target.is_dir(): - raise FileNotFoundError(f"Memory run directory missing: {target}") + available = _available_runs(base) + hint = "" + if available: + preview = ", ".join(available[:5]) + more = f" (+{len(available) - 5} more)" if len(available) > 5 else "" + hint = f"\nAvailable runs (newest first): {preview}{more}" + else: + hint = f"\nNo populated run directories found under {base}." + raise FileNotFoundError( + f"Memory run directory missing: {target}{hint}" + ) return target - candidates = [p for p in base.iterdir() if p.is_dir() and any(p.glob("*.json"))] - if not candidates: - raise FileNotFoundError(f"No run subdirectories found under {base}") - return max(candidates, key=lambda p: p.stat().st_mtime) + available = _available_runs(base) + if not available: + raise FileNotFoundError( + f"No run subdirectories with memory JSONs found under {base}." + ) + return base / available[0] # ── Retrieval index ─────────────────────────────────────────────────────── @@ -201,14 +232,32 @@ class MemoryIndex: def __init__(self, chunks: list[MemoryChunk], model_name: str = DEFAULT_EMBED_MODEL) -> None: """Embed all *chunks* with *model_name* (downloaded on first use).""" - from sentence_transformers import SentenceTransformer + try: + from sentence_transformers import SentenceTransformer + except ImportError as exc: + raise RuntimeError( + "sentence-transformers is required for memory chat. " + "Install it with: pip install sentence-transformers" + ) from exc self.chunks = chunks - self.model = SentenceTransformer(model_name) + try: + self.model = SentenceTransformer(model_name) + except Exception as exc: + raise RuntimeError( + f"Failed to load embedding model '{model_name}': {exc}. " + "Check your network connection (the model is downloaded " + "on first use) and that the model name is correct." + ) from exc texts = [c.embed_text for c in chunks] if texts: - vectors = self.model.encode( - texts, normalize_embeddings=True, show_progress_bar=False - ) + try: + vectors = self.model.encode( + texts, normalize_embeddings=True, show_progress_bar=False + ) + except Exception as exc: + raise RuntimeError( + f"Failed to embed {len(texts)} memory chunk(s): {exc}" + ) from exc self.matrix = np.asarray(vectors, dtype=np.float32) else: self.matrix = np.zeros((0, 0), dtype=np.float32) @@ -340,6 +389,7 @@ def __init__(self, config: Config, run_uuid: str | None = None) -> None: self.cursor = 0 # index into self.turns (which one we're viewing) self.scroll = 0 self.code_scroll = 0 + self._status_message: str = "" # transient banner shown on row 1 # -- bootstrap ----------------------------------------------------- def _bootstrap(self) -> None: @@ -398,6 +448,14 @@ def _draw_header(self, stdscr, width: int) -> None: stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) except curses.error: pass + # Transient status banner (one line, cleared after the next keypress). + if self._status_message: + try: + stdscr.attron(curses.color_pair(3)) + stdscr.addstr(1, 0, self._status_message.ljust(width)[:width]) + stdscr.attroff(curses.color_pair(3)) + except curses.error: + pass def _draw_help(self, stdscr, h: int, w: int) -> None: try: @@ -472,20 +530,58 @@ def _handle_ask(self, stdscr) -> None: return # Show a busy banner so the user knows we're querying stdscr.clear() - stdscr.addstr(0, 0, " Thinking… (retrieving + querying judge model)") - stdscr.refresh() - turn = self._ask(question) + try: + stdscr.addstr(0, 0, " Thinking… (retrieving + querying judge model)") + stdscr.refresh() + except curses.error: + pass + try: + turn = self._ask(question) + except Exception as exc: + # Never let an LLM/retrieval error crash the curses UI — surface + # the error inside a Turn so the user can keep browsing. + turn = _Turn( + question=question, + answer=f"[Error while answering this question: {exc}]", + hits=[], + ) + self._status_message = f"⚠ ask failed: {exc}" self.turns.append(turn) self.cursor = len(self.turns) - 1 self.scroll = 0 self.code_scroll = 0 + def _handle_reload(self) -> None: + """Reload memory chunks from disk and rebuild the embedding index.""" + try: + reloaded = load_memory_chunks(self.run_dir) + except Exception as exc: + self._status_message = f"⚠ reload failed: {exc}" + return + if not reloaded: + self._status_message = ( + f"⚠ reload found no memory chunks in {self.run_dir.name} " + "— keeping current index." + ) + return + try: + new_index = MemoryIndex(reloaded) + except Exception as exc: + self._status_message = f"⚠ reload index rebuild failed: {exc}" + return + self.chunks = reloaded + self.index = new_index + self._status_message = f"✓ reloaded {len(self.chunks)} chunks." + def _loop(self, stdscr) -> None: """Curses main loop — runs until the user quits.""" curses.curs_set(0) while True: self._render(stdscr) key = stdscr.getch() + # Clear transient status banner on the next keypress so it doesn't + # linger forever. + self._status_message = "" if key in (ord("q"), ord("Q")): return if key in (ord("a"), ord("A")): @@ -507,15 +603,19 @@ def _loop(self, stdscr) -> None: elif key == ord("C"): self.code_scroll = max(0, self.code_scroll - 5) elif key in (ord("r"), ord("R")): - self.chunks = load_memory_chunks(self.run_dir) - if self.chunks: - self.index = MemoryIndex(self.chunks) + self._handle_reload() # -- public entry -------------------------------------------------- def run(self) -> None: """Bootstrap the index/LLM and launch the curses UI.""" print(f" Loading memory from {self.run_dir} …") - self._bootstrap() + try: + self._bootstrap() + except RuntimeError as exc: + # Surfaced from MemoryIndex (embedding model load/encoding) or + # missing chunks. Print a clean message and bail without curses. + print(f"\n ❌ Could not initialise memory chat: {exc}\n") + return print(f" Loaded {len(self.chunks)} chunks. Embedding ready.") print(" Starting interactive UI…") From 60da1f059b3af36763b6b4575cd992aa5e66486c Mon Sep 17 00:00:00 2001 From: martin Date: Fri, 12 Jun 2026 10:34:54 +0200 Subject: [PATCH 10/71] feat(cli's): improved input error handling for all cli --- docs/images/evolution_tree.png | Bin 0 -> 218095 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/evolution_tree.png diff --git a/docs/images/evolution_tree.png b/docs/images/evolution_tree.png new file mode 100644 index 0000000000000000000000000000000000000000..6b878cb700f837d032fe933a676136c8a52bf4c9 GIT binary patch literal 218095 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

ipD^OQ0A#4z-XU}W^3 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%0A@@!P_Y=)FT|0D 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

Z6vd3;=*~+Ds31d7)jG30&6u?o;Kl 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

{fH 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 Date: Sat, 13 Jun 2026 12:21:23 +0200 Subject: [PATCH 11/71] rm : notes file --- config.py | 10 +-- ...e_selection_logistic_regression_dkpes.json | 34 ----------- run_notes/capsule_2704973191892021053.json | 34 ----------- .../clintox_multitask_toxicity_approval.json | 61 ------------------- run_notes/clintox_multitask_toxicity_fda.json | 40 ------------ ...dkpes_rf_signal_inhibition_prediction.json | 34 ----------- ...isualization_habitat_spatial_clusters.json | 34 ----------- ...outer_deepseek_deepseek-v3.2_one_shot.json | 46 ++++++++++++++ ...10_111004_run2_openai_gpt-4o_one_shot.json | 44 +++++++++++++ ...uter_deepseek_deepseek-v3.2_iterative.json | 33 ++++++++++ ...uter_deepseek_deepseek-v3.2_iterative.json | 32 ++++++++++ ...uter_deepseek_deepseek-v3.2_iterative.json | 0 run_notes/factors_tasks_correlations.json | 34 ----------- ...unctional_groups_dkpes_top10_bottom10.json | 34 ----------- run_notes/predict_bulk_modulus_rf.json | 34 ----------- sources/core/evolution_engine.py | 4 -- sources/core/workflow_factory.py | 3 - 17 files changed, 160 insertions(+), 351 deletions(-) delete mode 100644 run_notes/backward_feature_selection_logistic_regression_dkpes.json delete mode 100644 run_notes/capsule_2704973191892021053.json delete mode 100644 run_notes/clintox_multitask_toxicity_approval.json delete mode 100644 run_notes/clintox_multitask_toxicity_fda.json delete mode 100644 run_notes/dkpes_rf_signal_inhibition_prediction.json delete mode 100644 run_notes/elk_analysis_visualization_habitat_spatial_clusters.json create mode 100644 run_notes/evaluations/20260610_111004_run1_openrouter_deepseek_deepseek-v3.2_one_shot.json create mode 100644 run_notes/evaluations/20260610_111004_run2_openai_gpt-4o_one_shot.json create mode 100644 run_notes/evaluations/20260611_213744_run1_openrouter_deepseek_deepseek-v3.2_iterative.json create mode 100644 run_notes/evaluations/20260612_200423_run1_openrouter_deepseek_deepseek-v3.2_iterative.json create mode 100644 run_notes/evaluations/20260612_220833_run1_openrouter_deepseek_deepseek-v3.2_iterative.json delete mode 100644 run_notes/factors_tasks_correlations.json delete mode 100644 run_notes/functional_groups_dkpes_top10_bottom10.json delete mode 100644 run_notes/predict_bulk_modulus_rf.json diff --git a/config.py b/config.py index 31a74df..bcde659 100644 --- a/config.py +++ b/config.py @@ -38,17 +38,17 @@ class Config: def __init__(self): # workspace configuration - self.workspace_dir = "/Users/cnrs/Documents/repository/Toolomics/workspace" + self.workspace_dir = "/home/martin/Projects/CNRS/Toolomics/workspace_2" # MCPs server discovery self.discovery_addresses: list[AddressMCP] = [ - AddressMCP(ip="0.0.0.0", port_min=5000, port_max=5100) + AddressMCP(ip="0.0.0.0", port_min=5101, port_max=5200) ] # LLMs choices self.planner_llm_model: str = "openrouter/z-ai/glm-5.1" self.workflow_llm_model: str = "openrouter/z-ai/glm-5.1" - self.smolagent_model_id: str = "openrouter/mistralai/mistral-large-2407" + self.smolagent_model_id: str = "openrouter/deepseek/deepseek-v4-pro" self.judge_model = "openrouter/qwen/qwen3.7-max" self.capsule_namer_model = "openrouter/deepseek/deepseek-v4-flash" self.engine_name: str = "litellm" # for smolagent @@ -68,8 +68,8 @@ def __init__(self): self._model_pricing_cache = None # learning parameters - self.learned_score_threshold = 0.94 - self.max_learning_evolve_iterations = 35 + self.learned_score_threshold = 0.9 + self.max_learning_evolve_iterations = 20 # evaluation concurrency settings self.max_concurrent_eval_tasks: int = 2 # Number of concurrent tasks for CSV evaluation mode diff --git a/run_notes/backward_feature_selection_logistic_regression_dkpes.json b/run_notes/backward_feature_selection_logistic_regression_dkpes.json deleted file mode 100644 index a1edceb..0000000 --- a/run_notes/backward_feature_selection_logistic_regression_dkpes.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-08T11:18:35.243036", - "model": "openrouter/mistralai/mistral-large-2407", - "goal": "\nDOMAIN KNOWLEDGE:\nBackward feature selection is a feature selection technique where all available features are initially included in the fitted model. Then, features are sequentially removed based on their significance, with the least predictive feature being removed and a new model being fitted with the remaining features at each step. This process continues until the model's performance starts to decline or until some termination condition is met. To perform the backward feature selection, one can use SFS function from `mlxtend` library with appropriate arguments (e.g., set 'forward' to False).\n\nINSTRUCTIONS:\nPerform backward feature selection using logistic regression to identify the most relevant chemical features for predicting signal inhibition from the DKPES dataset. Binarize the signal inhibition values using appropriate threshold. Visualize the accuracy as a function of the number of selected features. Save the plot as \"pred_results/dkpes_feature_selection_analysis_pred.png\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_feature_selection_analysis_pred.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_visualization_3.py\n", - "execution_time_seconds": 503.6962056159973, - "analysis": "1. SUCCESS_LEVEL: Failed\n\n2. COMMENTS: \n- **What the workflow tried to do:** The single agent attempted to execute backward feature selection using logistic regression on the DKPES dataset, binarize the target variable (signal inhibition), and generate a visualization of accuracy as a function of the number of selected features.\n- **What worked:** The agent executed its internal logic and returned a \"SUCCESS\" status, claiming that the backward feature selection was completed and the results were saved.\n- **What failed and why:** Despite the agent's claim of success, the \"FILES USED, GENERATED OR MODIFIED\" section is completely empty. This is a critical failure, as it proves that neither the required plot (`pred_results/dkpes_feature_selection_analysis_pred.png`) nor the final Python script (`dkpes_visualization_3.py`) was actually generated or saved to the workspace. The agent either hallucinated the success message or the underlying code failed silently (e.g., due to missing directories, incorrect relative paths, or unhandled exceptions during the `mlxtend` SFS execution) without raising an error that the agent could catch.\n- **Areas for improvement:** The agent must never assume a task is complete just because the code ran without throwing a visible exception. It needs to implement strict post-execution validation, such as using `os.path.exists()` to verify that the exact expected files have been written to the correct paths before reporting success. Additionally, the agent should explicitly create necessary directories (like `pred_results/`) and implement robust error handling to capture and report silent failures during data processing, model fitting, and file I/O operations. An incomplete delivery of physical files renders the entire execution useless.", - "total_eval": 7, - "git": { - "commit": "9bc2185fcc9fade238d6c6e3c8619bb2b8bd28d0", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "backward_feature_selection_logistic_regression_dkpes", - "ver_success": 4, - "sr_success": 1, - "avg_cbs": 0.9101360835250049, - "total_cost": 4.840256, - "is_success": false, - "task_cost": 1.847152, - "max_judge_reward": 0.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "single_agent_20260608_110703_34a13434" - ], - "evolution_rewards": [ - 0.0 - ], - "evolution_costs": [ - 1.847152 - ], - "evolution_total_cost": 1.847152, - "evolution_avg_reward": 0.0, - "evolution_avg_cost": 1.847152 -} \ No newline at end of file diff --git a/run_notes/capsule_2704973191892021053.json b/run_notes/capsule_2704973191892021053.json deleted file mode 100644 index d418d5e..0000000 --- a/run_notes/capsule_2704973191892021053.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-08T11:01:27.713920", - "model": "openrouter/mistralai/mistral-large-2407", - "goal": "\nDOMAIN KNOWLEDGE:\nThe TanimotoCombo column presents the sum of the volumetric and chemical similarity components, where an exact match (two identical molecules in the same conformation) will result in a maximum score of 1 for each, summing to a maximum score of 2.\n\nINSTRUCTIONS:\nGiven the DKPES dataset, visualize the distribution of signal inhibition values and also visualize their relationship with the tanimoto similarity score. Save the figure as \"pred_results/dkpes_molecular_analysis_pred.png\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_molecular_analysis_pred.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_visualization_1.py\n", - "execution_time_seconds": 111.63464617729187, - "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \nThe single-agent workflow attempted to fulfill the data visualization task by generating a Python script to plot the distribution of 'Signal-inhibition' values and their relationship with the 'TanimotoCombo' score from the DKPES dataset. \n\n**What worked:** \nThe agent successfully interpreted the instructions, identified the correct columns from the dataset preview, and reportedly executed the code without any errors. It confirmed the creation of both the visualization image (`pred_results/dkpes_molecular_analysis_pred.png`) and the Python script (`dkpes_visualization_1.py`) at the exact requested paths.\n\n**What failed / Weaknesses:** \nWhile the agent self-reported a successful execution, the \"FILES USED, GENERATED OR MODIFIED\" section in the execution trace is completely empty. This is a significant logging failure, as it prevents external verification of whether the files were actually written to the correct directories or if the agent merely hallucinated its success. In a strict evaluation, relying solely on an agent's self-reported outcome without file system confirmation is a vulnerability. \n\n**Areas for improvement:** \nThe execution framework must ensure that file generation and modification events are properly captured and logged in the execution trace. Additionally, the agent's final answer is overly brief; it should ideally provide a summary of the visual insights (e.g., the shape of the distribution, the correlation observed between signal inhibition and Tanimoto similarity) to demonstrate that the analysis was actually performed and understood, rather than just confirming file creation. Assuming the files were indeed created as claimed, the goal was achieved.", - "total_eval": 5, - "git": { - "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "capsule_2704973191892021053", - "ver_success": 3, - "sr_success": 1, - "avg_cbs": 0.9163455709514656, - "total_cost": 2.87623, - "is_success": false, - "task_cost": 0.114444, - "max_judge_reward": 0.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "single_agent_20260608_105617_dbcffb2a" - ], - "evolution_rewards": [ - 0.0 - ], - "evolution_costs": [ - 0.114444 - ], - "evolution_total_cost": 0.114444, - "evolution_avg_reward": 0.0, - "evolution_avg_cost": 0.114444 -} \ No newline at end of file diff --git a/run_notes/clintox_multitask_toxicity_approval.json b/run_notes/clintox_multitask_toxicity_approval.json deleted file mode 100644 index 6a687b6..0000000 --- a/run_notes/clintox_multitask_toxicity_approval.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "timestamp": "2026-06-07T16:00:02.194167", - "model": "openrouter/deepseek/deepseek-v4-flash", - "goal": "\nDOMAIN KNOWLEDGE:\n1. *On the task*: The ClinTox dataset contains drugs approved by the US Food and Drug Administration (FDA) and drugs that have failed clinical trials for toxicity reasons. The dataset includes two binary classification tasks for 1491 drug compounds with known chemical structures: (1) clinical trial toxicity (or absence of toxicity) and (2) FDA approval status. This requires developing a model with two binary classification heads each determining the label of its respective task based on the molecular structure. Use `MultitaskClassifier` model from the deepchem library.\n2. *On featurization*: To represent the molecular structure, use Extended-Connectivity Fingerprints (ECFPs) featurization in deepchem. ECFPs are circular topological fingerprints that represent the presence of particular substructures and stereochemical information.\n\nINSTRUCTIONS:\nTrain a multitask model on the Clintox dataset to predict a drug's toxicity and FDA approval status. Save the test set predictions, including the SMILES representation of drugs and the probability of positive labels, to \"pred_results/clintox_test_pred.csv\".\n\nDATASET STRUCTURE:\n|-- clintox/\n|---- clintox_test.csv\n|---- clintox_train.csv\n\nDATASET PREVIEW:\n[START Preview of clintox/clintox_train.csv]\nsmiles,FDA_APPROVED,CT_TOX\nCCC(/C=C/Cl)(C#C)O,1,0\nC[C@H]1C[C@H]2[C@@H]3CC[C@@H]([C@]3(C[C@@H]([C@@H]2[C@@]4(C1=CC(=O)CC4)C)O)C)C(=O)C,1,0\nC[C@@H]1CCN([C@H](C1)C(=O)[O-])C(=O)[C@H](CCC[NH+]=C(N)N)NS(=O)(=O)c2cccc3c2NC[C@@H](C3)C,1,0\n...\n[END Preview of clintox/clintox_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/clintox_test_pred.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: clintox_nn.py\n", - "execution_time_seconds": 6826.115881443024, - "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \n- **What the workflow tried to do**: The multi-agent system aimed to train a DeepChem `MultitaskClassifier` using Extended-Connectivity Fingerprints (ECFPs) on the ClinTox dataset. The goal was to predict clinical trial toxicity and FDA approval status, and save the test set predictions (SMILES and positive-class probabilities) to a specific CSV path, while keeping a single Python script.\n- **What worked**: The agents demonstrated excellent problem-solving skills by identifying and resolving a critical environment incompatibility between DeepChem 2.5.0 and TensorFlow 2.16.1 via monkey-patching. The model was successfully trained, and the validator confirmed that the output CSV contains actual probabilities (evidenced by the high number of unique values in the prediction columns) rather than just binary labels. All required artifacts (`clintox_nn.py` and `pred_results/clintox_test_pred.csv`) were generated and saved in the exact requested directories.\n- **What failed or needs improvement**: There is a minor data integrity flaw: the prediction file contains 292 rows for 291 unique test SMILES, meaning one drug's prediction was duplicated. Additionally, the builder reported a `NaN` ROC-AUC score, assuming the test set had \"all same labels,\" which is highly unlikely for the standard ClinTox test split and suggests a potential bug in how the true labels were handled during evaluation (though calculating ROC-AUC was not strictly required by the prompt). Finally, the execution time was exceptionally long (~113 minutes), which could be optimized by reducing epoch counts or streamlining the environment setup.\n- **Overall**: The primary objectives were fully and successfully met. The minor anomalies in row duplication and evaluation metrics do not invalidate the core deliverables, making this a highly successful execution.", - "total_eval": 1, - "git": { - "commit": "01e640fe023e47e7d1f047d0eec9795e11de96a1", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "clintox_multitask_toxicity_approval", - "ver_success": 0, - "sr_success": 0, - "avg_cbs": 0.8916471626811059, - "total_cost": 3.8073243219, - "is_success": false, - "task_cost": 3.8073243219, - "max_judge_reward": 0.9567, - "evolution_iterations": 10, - "evolved_workflows_uuids": [ - "20260607_140047_ea386abb", - "20260607_141634_fdc8df1c", - "20260607_143732_49c1f07d", - "20260607_144649_2403f41f", - "generation_failed", - "20260607_145952_22b94e73", - "20260607_151436_88454304", - "generation_failed", - "20260607_152835_c7101065", - "20260607_154504_9751225a" - ], - "evolution_rewards": [ - 0.9108, - 0.7313, - 0.8614, - 0.7142, - 0.0, - 0.7459, - 0.2941, - 0.0, - 0.0, - 0.9567 - ], - "evolution_costs": [ - 1.0641413546999998, - 1.0532012297, - 0.20088649629999988, - 0.3192504145999999, - 0.005209605100000125, - 0.6119852561000001, - 0.3540861274, - 0.005209605100000125, - 0.029022980000000143, - 0.16433125289999984 - ], - "evolution_total_cost": 3.8073243219, - "evolution_avg_reward": 0.52144, - "evolution_avg_cost": 0.38073243218999997 -} \ No newline at end of file diff --git a/run_notes/clintox_multitask_toxicity_fda.json b/run_notes/clintox_multitask_toxicity_fda.json deleted file mode 100644 index b625847..0000000 --- a/run_notes/clintox_multitask_toxicity_fda.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "timestamp": "2026-06-08T13:03:50.750624", - "model": "openrouter/deepseek/deepseek-v4-flash", - "goal": "\nDOMAIN KNOWLEDGE:\n1. *On the task*: The ClinTox dataset contains drugs approved by the US Food and Drug Administration (FDA) and drugs that have failed clinical trials for toxicity reasons. The dataset includes two binary classification tasks for 1491 drug compounds with known chemical structures: (1) clinical trial toxicity (or absence of toxicity) and (2) FDA approval status. This requires developing a model with two binary classification heads each determining the label of its respective task based on the molecular structure. Use `MultitaskClassifier` model from the deepchem library.\n2. *On featurization*: To represent the molecular structure, use Extended-Connectivity Fingerprints (ECFPs) featurization in deepchem. ECFPs are circular topological fingerprints that represent the presence of particular substructures and stereochemical information.\n\nINSTRUCTIONS:\nTrain a multitask model on the Clintox dataset to predict a drug's toxicity and FDA approval status. Save the test set predictions, including the SMILES representation of drugs and the probability of positive labels, to \"pred_results/clintox_test_pred.csv\".\n\nDATASET STRUCTURE:\n|-- clintox/\n|---- clintox_test.csv\n|---- clintox_train.csv\n\nDATASET PREVIEW:\n[START Preview of clintox/clintox_train.csv]\nsmiles,FDA_APPROVED,CT_TOX\nCCC(/C=C/Cl)(C#C)O,1,0\nC[C@H]1C[C@H]2[C@@H]3CC[C@@H]([C@]3(C[C@@H]([C@@H]2[C@@]4(C1=CC(=O)CC4)C)O)C)C(=O)C,1,0\nC[C@@H]1CCN([C@H](C1)C(=O)[O-])C(=O)[C@H](CCC[NH+]=C(N)N)NS(=O)(=O)c2cccc3c2NC[C@@H](C3)C,1,0\n...\n[END Preview of clintox/clintox_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/clintox_test_pred.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: clintox_nn.py\n", - "execution_time_seconds": 2428.3582360744476, - "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \nThe multi-agent workflow successfully completed the assigned task, demonstrating strong problem-solving and debugging capabilities. \n\n**What worked:**\n- **Task Completion:** The builder agent successfully trained a `MultitaskClassifier` using ECFP featurization on the ClinTox dataset and saved the test predictions to the exact required path (`pred_results/clintox_test_pred.csv`). The final script was correctly saved as `clintox_nn.py`.\n- **Autonomous Debugging:** The builder encountered multiple environment and library issues, including missing packages (numpy, tensorflow, rdkit), array shape errors during featurization, and DeepChem/TensorFlow version incompatibilities. It autonomously resolved all of these by installing dependencies, rewriting the featurization process to handle SMILES individually with a zero-vector fallback, and applying a `tf-keras` compatibility patch.\n- **Validation:** The grounded validator thoroughly verified the outputs, confirming the correct row count (292), valid probability ranges for the predictions, and the mathematical correctness of the undefined ROC-AUC scores (due to the test set containing only the positive class).\n\n**What failed / Areas for improvement:**\n- **Execution Time:** The execution took roughly 40 minutes (2428 seconds), largely due to the iterative trial-and-error process of resolving dependency and compatibility errors. Pre-configuring the environment with the correct `deepchem`, `tensorflow`, and `rdkit` versions would significantly speed up future runs.\n- **Output Clarity:** While the output CSV correctly contains the probabilities of the positive labels as requested, the column names (`FDA_APPROVED`, `CT_TOX`) are identical to the true label columns. Renaming them to `FDA_APPROVED_prob` and `CT_TOX_prob` would have prevented any ambiguity between true labels and predicted probabilities.\n\n**Scalability:**\nThe approach is highly scalable and robust. The implementation of individual SMILES processing with fallback mechanisms and the proactive handling of TensorFlow/Keras compatibility layers ensures that the script can reliably process similar chemical datasets without crashing on invalid molecules or library version mismatches.", - "total_eval": 4, - "git": { - "commit": "6a18b3ebc443f65d4fc157e251f9fab43ce27038", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "clintox_multitask_toxicity_fda", - "ver_success": 3, - "sr_success": 2, - "avg_cbs": 0.9354476347373322, - "total_cost": 10.470079470499998, - "is_success": true, - "task_cost": 5.460983531299999, - "max_judge_reward": 0.9408, - "evolution_iterations": 3, - "evolved_workflows_uuids": [ - "20260608_121947_624cad65", - "generation_failed", - "20260608_124321_28dbc7cc" - ], - "evolution_rewards": [ - 0.81, - 0.0, - 0.9408 - ], - "evolution_costs": [ - 5.022916422299999, - 0.005209605100000125, - 0.4328575039000002 - ], - "evolution_total_cost": 5.460983531299999, - "evolution_avg_reward": 0.5836, - "evolution_avg_cost": 1.8203278437666663 -} \ No newline at end of file diff --git a/run_notes/dkpes_rf_signal_inhibition_prediction.json b/run_notes/dkpes_rf_signal_inhibition_prediction.json deleted file mode 100644 index e429b0f..0000000 --- a/run_notes/dkpes_rf_signal_inhibition_prediction.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-08T10:56:16.329784", - "model": "openrouter/mistralai/mistral-large-2407", - "goal": "\nDOMAIN KNOWLEDGE:\nNone\n\nINSTRUCTIONS:\nUse the DKPES dataset to develop a Random Forest classifier predicting signal inhibition of chemicals while choosing appropriate threshold to assign binary labels based on signal inhibition values. Save the test set predictions, including the index and predicted signal inhibition, in \"pred_results/dkpes_test_pred.csv\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_test_pred.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_model_development_1.py\n", - "execution_time_seconds": 315.0821957588196, - "analysis": "1. SUCCESS_LEVEL: Failed\n\n2. COMMENTS: The multi-agent workflow (consisting of a single agent) completely failed to achieve the objective. The agent attempted to train a Random Forest classifier on the DKPES dataset but encountered a fundamental data preprocessing error. The dataset includes a categorical/string column named 'ShapeQuery' (e.g., 'DKPES_CSD_MMMF_1_32'), which cannot be processed by Scikit-Learn's Random Forest implementation without prior encoding or exclusion. \n\nWhile the agent correctly identified the source of the `ValueError: could not convert string to float`, it demonstrated poor problem-solving capabilities by failing to successfully implement a fix (such as simply dropping the column or applying label encoding) despite multiple attempts. As a result of this basic preprocessing failure, the model was never trained, no threshold was determined, and neither the required prediction CSV file (`pred_results/dkpes_test_pred.csv`) nor the final Python script (`dkpes_model_development_1.py`) were generated. The execution was a complete failure due to the agent's inability to handle standard tabular data cleaning tasks.", - "total_eval": 4, - "git": { - "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "dkpes_rf_signal_inhibition_prediction", - "ver_success": 2, - "sr_success": 1, - "avg_cbs": 0.9229156532575468, - "total_cost": 2.761786, - "is_success": false, - "task_cost": 1.283458, - "max_judge_reward": 0.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "single_agent_20260608_104814_625b602d" - ], - "evolution_rewards": [ - 0.0 - ], - "evolution_costs": [ - 1.283458 - ], - "evolution_total_cost": 1.283458, - "evolution_avg_reward": 0.0, - "evolution_avg_cost": 1.283458 -} \ No newline at end of file diff --git a/run_notes/elk_analysis_visualization_habitat_spatial_clusters.json b/run_notes/elk_analysis_visualization_habitat_spatial_clusters.json deleted file mode 100644 index fa0ba1b..0000000 --- a/run_notes/elk_analysis_visualization_habitat_spatial_clusters.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-08T10:48:14.017179", - "model": "openrouter/mistralai/mistral-large-2407", - "goal": "\nDOMAIN KNOWLEDGE:\n\"Home range\" can be defined as the area within which an animal normally lives and finds what it needs for survival. Basically, the home range is the area that an animal travels for its normal daily activities. \"Minimum Bounding Geometry\" creates a feature class containing polygons which represent a specified minimum bounding geometry enclosing each input feature or each group of input features. \"Convex hull\" is the smallest convex polygon that can enclose a group of objects, such as a group of points.\n\nINSTRUCTIONS:\nAnalyze and visualize Elk movements in the given dataset. Estimate home ranges and assess habitat preferences using spatial analysis techniques. Identify the spatial clusters of Elk movements. Document the findings with maps and visualizations. Save the figure as \"pred_results/Elk_Analysis.png\".\n\nDATASET STRUCTURE:\n|-- ElkMovement/\n|---- Elk_in_Southwestern_Alberta_2009.geojson\n\nDATASET PREVIEW:\n[START Preview of ElkMovement/Elk_in_Southwestern_Alberta_2009.geojson]\n{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"id\":1,\"geometry\":{\"type\":\"Point\",\"coordinates\":[-114.19111179959417,49.536741600111178]},\"properties\":{\"OBJECTID\":1,\"timestamp\":\"2009-01-01 01:00:37\",\"long\":-114.1911118,\"lat\":49.536741599999999,\"comments\":\"Carbondale\",\"external_t\":-5,\"dop\":2.3999999999999999,\"fix_type_r\":\"3D\",\"satellite_\":0,\"height\":1375.1900000000001,\"crc_status\":\" \",\"outlier_ma\":0,\"sensor_typ\":\"gps\",\"individual\":\"Cervus elaphus\",\"tag_ident\":\"856\",\"ind_ident\":\"E001\",\"study_name\":\"Elk in southwestern Alberta\",\"date\":1709164800000,\"time\":\" \",\"timestamp_Converted\":1230771637000,\"summer_indicator\":1}},{\"type\":\"Feature\",\"id\":2,\"geometry\":{\"type\":\"Point\",\"coordinates\":[-114.1916239994119,49.536505999952517]},\"properties\":{\"OBJECTID\":2,\"timestamp\":\"2009-01-01 03:00:52\",\"long\":-114.191624,\"lat\":49.536506000000003,\"comments\":\"Carbondale\",\"external_t\":-6,\"dop\":2.3999999999999999,\"fix_type_r\":\"3D\",\"satellite_\":0,\"height\":1375.2,\"crc_status\":\" \",\"outlier_ma\":0,\"sensor_typ\":\"gps\",\"individual\":\"Cervus elaphus\",\"tag_ident\":\"856\",\"ind_ident\":\"E001\",\"study_name\":\"Elk in southwestern Alberta\",\"date\":1709164800000,\"time\":\" \",\"timestamp_Converted\":1230778852000,\"summer_indicator\":1}},{\"type\":\"Feature\",\"id\":3,\"geometry\":{\"type\":\"Point\",\"coordinates\":[-114.19169140075056,49.536571800069581]},\"properties\":{\"OBJECTID\":3,\"timestamp\":\"2009-01-01 05:00:49\",\"long\":-114.1916914,\"lat\":49.536571799999997,\"comments\":\"Carbondale\",\"external_t\":-6,\"dop\":5.6000000000000014,\"fix_type_r\":\"3D\",\"satellite_\":0,\"height\":1382.0999999999999,\"crc_status\":\" \",\"outlier_ma\":0,\"sensor_typ\":\"gps\",\"individual\":\"Cervus elaphus\",\"tag_ident\":\"856\",\"ind_ident\":\"E001\",\"study_name\":\"Elk in southwestern Alberta\",\"date\":1709164800000,\"time\":\" \",\"timestamp_Converted\":1230786049000,\"summer_indicator\":1}},...]}\n[END Preview of ElkMovement/Elk_in_Southwestern_Alberta_2009.geojson]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/Elk_Analysis.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: elk_new.py\n", - "execution_time_seconds": 185.73629331588745, - "analysis": "1. SUCCESS_LEVEL: Failed\n\n2. COMMENTS: \nThe single-agent workflow attempted to analyze elk movements, estimate home ranges, and visualize the spatial data. It successfully generated the required output files (`pred_results/Elk_Analysis.png` and `elk_new.py`) and applied a basic minimum bounding geometry (convex hull) to estimate home ranges. \n\nHowever, the execution failed to achieve the full goal due to significant omissions:\n- **Missing Analysis:** The agent completely ignored the instructions to \"assess habitat preferences\" and \"identify the spatial clusters of Elk movements.\" No clustering algorithms (e.g., DBSCAN, KDE) or habitat preference evaluations (e.g., analyzing the `height` attribute or land use) were performed.\n- **Incomplete Data Processing:** The agent arbitrarily restricted the analysis to only 3 out of 68 individuals in the dataset. While it noted this was a \"representative sample,\" an autonomous scientific workflow should process the entire dataset or a statistically robust sample unless constrained by computational limits. \n- **Basic Methodology:** The spatial analysis techniques were overly simplistic, relying solely on convex hulls without exploring more nuanced spatial patterns required by the prompt.\n\nTo improve, future executions must strictly parse and address every analytical requirement in the prompt, implement appropriate clustering and habitat assessment techniques, and process the full dataset rather than an arbitrarily small subset.", - "total_eval": 3, - "git": { - "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "elk_analysis_visualization_habitat_spatial_clusters", - "ver_success": 2, - "sr_success": 1, - "avg_cbs": 0.9338881262918104, - "total_cost": 1.478328, - "is_success": false, - "task_cost": 0.167122, - "max_judge_reward": 0.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "single_agent_20260608_104132_34852165" - ], - "evolution_rewards": [ - 0.0 - ], - "evolution_costs": [ - 0.167122 - ], - "evolution_total_cost": 0.167122, - "evolution_avg_reward": 0.0, - "evolution_avg_cost": 0.167122 -} \ No newline at end of file diff --git a/run_notes/evaluations/20260610_111004_run1_openrouter_deepseek_deepseek-v3.2_one_shot.json b/run_notes/evaluations/20260610_111004_run1_openrouter_deepseek_deepseek-v3.2_one_shot.json new file mode 100644 index 0000000..6aef8fc --- /dev/null +++ b/run_notes/evaluations/20260610_111004_run1_openrouter_deepseek_deepseek-v3.2_one_shot.json @@ -0,0 +1,46 @@ +{ + "started_at": "2026-06-10T11:10:04.790014", + "run_id": 1, + "smolagent_model_id": "openrouter/deepseek/deepseek-v3.2", + "workflow_llm_model": "openrouter/z-ai/glm-5.1", + "judge_model": "openrouter/qwen/qwen3.7-max", + "eval_mode": "one_shot", + "csv_runs_limit": 10, + "discovery_addresses": [ + { + "ip": "0.0.0.0", + "port_min": 5000, + "port_max": 5100 + } + ], + "workspace_dir": "/home/martin/Projects/CNRS/Toolomics/workspace", + "detected_mcps": [ + "mcp_5000: 4 tools available | running on 0.0.0.0:5000", + "mcp_5001: 5 tools available | running on 0.0.0.0:5001", + "mcp_5002: 12 tools available | running on 0.0.0.0:5002", + "mcp_5003: 3 tools available | running on 0.0.0.0:5003", + "mcp_5004: 10 tools available | running on 0.0.0.0:5004", + "mcp_5005: 5 tools available | running on 0.0.0.0:5005", + "mcp_5006: 10 tools available | running on 0.0.0.0:5006", + "mcp_5007: 3 tools available | running on 0.0.0.0:5007", + "mcp_5008: 6 tools available | running on 0.0.0.0:5008", + "mcp_5010: 5 tools available | running on 0.0.0.0:5010", + "mcp_5016: 2 tools available | running on 0.0.0.0:5016", + "mcp_5100: 4 tools available | running on 0.0.0.0:5100" + ], + "dataset": "datasets/ScienceAgentBench.csv", + "status": "completed", + "queue_size": 2, + "final_results": { + "steps_evaluated": 10, + "successful_runs": 10, + "success_rate": "100.0%", + "ver_success": 1, + "ver_total": 10, + "sr_success": 0, + "sr_total": 10, + "avg_cbs": 0.8805598683177924, + "total_cost": 2.5144287343999996 + }, + "finished_at": "2026-06-10T13:59:23.750264" +} diff --git a/run_notes/evaluations/20260610_111004_run2_openai_gpt-4o_one_shot.json b/run_notes/evaluations/20260610_111004_run2_openai_gpt-4o_one_shot.json new file mode 100644 index 0000000..25b34a6 --- /dev/null +++ b/run_notes/evaluations/20260610_111004_run2_openai_gpt-4o_one_shot.json @@ -0,0 +1,44 @@ +{ + "started_at": "2026-06-10T11:10:04.790236", + "run_id": 2, + "smolagent_model_id": "openai/gpt-4o", + "workflow_llm_model": "openrouter/z-ai/glm-5.1", + "judge_model": "openrouter/qwen/qwen3.7-max", + "eval_mode": "one_shot", + "csv_runs_limit": 10, + "discovery_addresses": [ + { + "ip": "0.0.0.0", + "port_min": 5101, + "port_max": 5201 + } + ], + "workspace_dir": "/home/martin/Projects/CNRS/Toolomics/workspace_2", + "detected_mcps": [ + "mcp_5101: 5 tools available | running on 0.0.0.0:5101", + "mcp_5102: 5 tools available | running on 0.0.0.0:5102", + "mcp_5103: 2 tools available | running on 0.0.0.0:5103", + "mcp_5104: 12 tools available | running on 0.0.0.0:5104", + "mcp_5105: 3 tools available | running on 0.0.0.0:5105", + "mcp_5106: 10 tools available | running on 0.0.0.0:5106", + "mcp_5107: 5 tools available | running on 0.0.0.0:5107", + "mcp_5108: 10 tools available | running on 0.0.0.0:5108", + "mcp_5109: 3 tools available | running on 0.0.0.0:5109", + "mcp_5110: 6 tools available | running on 0.0.0.0:5110" + ], + "dataset": "datasets/ScienceAgentBench.csv", + "status": "completed", + "queue_size": 2, + "final_results": { + "steps_evaluated": 10, + "successful_runs": 9, + "success_rate": "90.0%", + "ver_success": 0, + "ver_total": 9, + "sr_success": 0, + "sr_total": 9, + "avg_cbs": 0.8884085380397574, + "total_cost": 8.18470568 + }, + "finished_at": "2026-06-10T13:27:23.909575" +} diff --git a/run_notes/evaluations/20260611_213744_run1_openrouter_deepseek_deepseek-v3.2_iterative.json b/run_notes/evaluations/20260611_213744_run1_openrouter_deepseek_deepseek-v3.2_iterative.json new file mode 100644 index 0000000..a59ab3c --- /dev/null +++ b/run_notes/evaluations/20260611_213744_run1_openrouter_deepseek_deepseek-v3.2_iterative.json @@ -0,0 +1,33 @@ +{ + "started_at": "2026-06-11T21:37:44.690468", + "run_id": 1, + "smolagent_model_id": "openrouter/deepseek/deepseek-v3.2", + "workflow_llm_model": "openrouter/z-ai/glm-5.1", + "judge_model": "openrouter/z-ai/glm-5.1", + "eval_mode": "iterative", + "csv_runs_limit": 10, + "discovery_addresses": [ + { + "ip": "0.0.0.0", + "port_min": 5000, + "port_max": 5100 + } + ], + "workspace_dir": "/home/martin/Projects/CNRS/Toolomics/workspace", + "detected_mcps": [ + "mcp_5000: 4 tools available | running on 0.0.0.0:5000", + "mcp_5001: 5 tools available | running on 0.0.0.0:5001", + "mcp_5002: 12 tools available | running on 0.0.0.0:5002", + "mcp_5003: 3 tools available | running on 0.0.0.0:5003", + "mcp_5004: 10 tools available | running on 0.0.0.0:5004", + "mcp_5005: 5 tools available | running on 0.0.0.0:5005", + "mcp_5006: 10 tools available | running on 0.0.0.0:5006", + "mcp_5007: 3 tools available | running on 0.0.0.0:5007", + "mcp_5008: 6 tools available | running on 0.0.0.0:5008", + "mcp_5010: 5 tools available | running on 0.0.0.0:5010", + "mcp_5016: 2 tools available | running on 0.0.0.0:5016" + ], + "dataset": "datasets/ScienceAgentBench.csv", + "status": "running", + "queue_size": 1 +} diff --git a/run_notes/evaluations/20260612_200423_run1_openrouter_deepseek_deepseek-v3.2_iterative.json b/run_notes/evaluations/20260612_200423_run1_openrouter_deepseek_deepseek-v3.2_iterative.json new file mode 100644 index 0000000..bdd003c --- /dev/null +++ b/run_notes/evaluations/20260612_200423_run1_openrouter_deepseek_deepseek-v3.2_iterative.json @@ -0,0 +1,32 @@ +{ + "started_at": "2026-06-12T20:04:23.355942", + "run_id": 1, + "smolagent_model_id": "openrouter/deepseek/deepseek-v3.2", + "workflow_llm_model": "openrouter/z-ai/glm-5.1", + "judge_model": "openrouter/z-ai/glm-5.1", + "eval_mode": "iterative", + "csv_runs_limit": 10, + "discovery_addresses": [ + { + "ip": "0.0.0.0", + "port_min": 5000, + "port_max": 5100 + } + ], + "workspace_dir": "/home/martin/Projects/CNRS/Toolomics/workspace", + "detected_mcps": [ + "mcp_5000: 4 tools available | running on 0.0.0.0:5000", + "mcp_5001: 5 tools available | running on 0.0.0.0:5001", + "mcp_5002: 12 tools available | running on 0.0.0.0:5002", + "mcp_5003: 3 tools available | running on 0.0.0.0:5003", + "mcp_5004: 10 tools available | running on 0.0.0.0:5004", + "mcp_5005: 5 tools available | running on 0.0.0.0:5005", + "mcp_5006: 10 tools available | running on 0.0.0.0:5006", + "mcp_5008: 6 tools available | running on 0.0.0.0:5008", + "mcp_5011: 5 tools available | running on 0.0.0.0:5011", + "mcp_5016: 2 tools available | running on 0.0.0.0:5016" + ], + "dataset": "datasets/ScienceAgentBench.csv", + "status": "running", + "queue_size": 1 +} diff --git a/run_notes/evaluations/20260612_220833_run1_openrouter_deepseek_deepseek-v3.2_iterative.json b/run_notes/evaluations/20260612_220833_run1_openrouter_deepseek_deepseek-v3.2_iterative.json new file mode 100644 index 0000000..e69de29 diff --git a/run_notes/factors_tasks_correlations.json b/run_notes/factors_tasks_correlations.json deleted file mode 100644 index 71c230b..0000000 --- a/run_notes/factors_tasks_correlations.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-08T11:41:56.912992", - "model": "openrouter/mistralai/mistral-large-2407", - "goal": "\nDOMAIN KNOWLEDGE:\nNone\n\nINSTRUCTIONS:\nCompute the correlations of FACTORS tasks and save the results. Load the FACTORS dataset, compute the Pearson correlation coefficient between each task and every other task. Save the computed results as a histogram to \"./pred_results/Factors_correlations.png\".\n\nDATASET STRUCTURE:\n|-- FACTORS/\n|---- FACTORS_training_disguised_combined_full.csv\n|---- FACTORS_test1_disguised_combined.csv\n|---- FACTORS_test2_disguised_combined.csv\n|---- scripts/\n|------ FACTORS_features.py\n|------ FACTORS_datasets.py\n\nDATASET PREVIEW:\n[START Preview of benchmark/datasets/FACTORS/FACTORS_training_distinguised_combined_full.csv]\nMolecule,D_00001,D_00002,D_00003,D_00004,D_00005, ...\nM_0164851,0,0,0,0,0, ...\nM_0164852,0,0,0,0,0, ...\n...\n[END Preview of benchmark/datasets/FACTORS/FACTORS_training_distinguised_combined_full.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/Factors_correlations.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: FACTORS_correlations.py\n", - "execution_time_seconds": 929.0803868770599, - "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: The workflow, executed by a single agent, successfully completed the assigned computational and visualization task. The agent correctly interpreted the instructions to load the FACTORS dataset, compute the Pearson correlation coefficients between all task pairs, and save the distribution of these correlations as a histogram. The expected output files (`pred_results/Factors_correlations.png` and `FACTORS_correlations.py`) were reportedly generated at the exact specified paths, and the execution finished well within the time limit (929 seconds) without encountering any errors.\n\nStrengths:\n- Precise adherence to instructions: The agent correctly identified the required statistical method (Pearson correlation) and the exact output formats and paths.\n- Efficiency: A single agent was sufficient to handle the data loading, computation, and plotting steps without unnecessary multi-agent overhead.\n\nWeaknesses & Areas for Improvement:\n- File Tracking Anomaly: A strict observation is that the \"FILES USED, GENERATED OR MODIFIED\" log in the execution trace is completely empty. While the agent reported a successful status and provided the correct file paths, the lack of file system tracking confirmation suggests a potential blind spot in the environment's logging mechanism, or the files were generated in an untracked virtual environment/memory rather than the persistent workspace. \n- Verification: In a strictly autonomous scientific pipeline, self-reported success without environmental file-tracking verification is a vulnerability. Future executions must ensure that file generation is properly captured and verified by the system's file tracker to guarantee verifiable reproducibility and prevent \"hallucinated\" file creations.", - "total_eval": 8, - "git": { - "commit": "9bc2185fcc9fade238d6c6e3c8619bb2b8bd28d0", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "factors_tasks_correlations", - "ver_success": 4, - "sr_success": 1, - "avg_cbs": 0.9056779066322573, - "total_cost": 4.997864, - "is_success": false, - "task_cost": 0.157608, - "max_judge_reward": 0.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "single_agent_20260608_111836_93bbaa32" - ], - "evolution_rewards": [ - 0.0 - ], - "evolution_costs": [ - 0.157608 - ], - "evolution_total_cost": 0.157608, - "evolution_avg_reward": 0.0, - "evolution_avg_cost": 0.157608 -} \ No newline at end of file diff --git a/run_notes/functional_groups_dkpes_top10_bottom10.json b/run_notes/functional_groups_dkpes_top10_bottom10.json deleted file mode 100644 index 1f5b372..0000000 --- a/run_notes/functional_groups_dkpes_top10_bottom10.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-08T11:07:02.757927", - "model": "openrouter/mistralai/mistral-large-2407", - "goal": "\nDOMAIN KNOWLEDGE:\n1. *On functional groups and activity*: In cheminformatics, functional groups like hydroxyl, keto, and amino substructures play a crucial role in determining molecular properties, including biological activity. The interaction between small molecules with receptors (e.g., proteins) or enzymes depends on the presence of these groups, affecting inhibitory activity. Therefore, visualizing the presence or absence of functional groups in the top and least active molecules can reveal structure-activity relationships (SAR).\n\n2. *On signal inhibition as a measure of activity*: Signal inhibition is a standard metric (e.g., IC50, Kd) used to quantify how effectively a molecule can suppress or inhibit biological signals. In this dataset, molecules with the highest and lowest values in the `Signal-inhibition` column represent the most and least active molecules, respectively. The next 11 columns represent the count of specific functional groups.\n\nINSTRUCTIONS:\nVisualize the distribution of functional groups for the 10 most and 10 least active molecules in the DKPES dataset. Save the figure as \"pred_results/dkpes_molecular_activity_analysis_pred.png\".\n\nDATASET STRUCTURE:\n|-- dkpes/\n|---- dkpes_test.csv\n|---- dkpes_train.csv\n\nDATASET PREVIEW:\n[START Preview of dkpes/dkpes_train.csv]\nindex,Signal-inhibition,3-Keto,3-Hydroxy,12-Keto,12-Hydroxy,19-Methyl,18-Methyl,Sulfate-Ester,Sulfate-Oxygens,C4-C5-DB,C6-C7-DB,Sulfur,ShapeQuery,TanimotoCombo,ShapeTanimoto,ColorTanimoto,FitTverskyCombo,FitTversky,FitColorTversky,RefTverskyCombo,RefTversky,RefColorTversky,ScaledColor,ComboScore,ColorScore,Overlap\nZINC04026280,0.24,0,0,0,0,0,1,0,0,0,0,0,DKPES_CSD_MMMF_1_32,1.184,0.708,0.476,1.692,0.886,0.806,1.316,0.779,0.537,0.528,1.235,-5.804,1045.931\nZINC78224296,0.278,0,0,0,0,0,1,0,3,0,0,1,DKPES_CSD_MMMF_1_31,1.063,0.765,0.298,1.346,0.904,0.442,1.31,0.832,0.478,0.48,1.245,-5.278,1122.302\nZINC01532179,0.686,0,0,0,0,0,0,1,3,0,0,1,DKPES_CSD_MMMF_1_16,0.965,0.633,0.332,1.896,1.143,0.752,0.959,0.586,0.373,0.363,0.995,-3.988,770.823\n...\n[END Preview of dkpes/dkpes_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/dkpes_molecular_activity_analysis_pred.png\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: dkpes_visualization_2.py\n", - "execution_time_seconds": 164.39737749099731, - "analysis": "1. SUCCESS_LEVEL: Medium\n\n2. COMMENTS: \nThe multi-agent workflow (consisting of a single agent) attempted to read the DKPES dataset, identify the 10 most and 10 least active molecules based on the `Signal-inhibition` column, and visualize the distribution of the 11 specified functional groups. The agent reported a successful execution, claiming that the visualization was saved to the exact required path (`pred_results/dkpes_molecular_activity_analysis_pred.png`) and the Python script was saved as `dkpes_visualization_2.py` without encountering any errors. \n\nHowever, the \"FILES USED, GENERATED OR MODIFIED\" section in the execution log is completely empty. This is a critical weakness, as it raises significant doubt about whether the files were actually written to the disk, saved in the correct directories, or if the environment simply failed to track the file I/O operations. Because there is no concrete evidence of file generation in the system logs, a full \"High\" success rating cannot be awarded. \n\nThe approach of using a single agent for this straightforward data manipulation and visualization task is logically sound, efficient, and highly scalable for similar cheminformatics exploratory data analysis (EDA) goals. For future executions, the system must ensure that file creation is strictly verified and properly logged to confirm task completion.", - "total_eval": 6, - "git": { - "commit": "b0f4ea7f7521b2eebbd385b33d6f721971008153", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "functional_groups_dkpes_top10_bottom10", - "ver_success": 4, - "sr_success": 1, - "avg_cbs": 0.9161655723651498, - "total_cost": 2.993104, - "is_success": false, - "task_cost": 0.116874, - "max_judge_reward": 0.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "single_agent_20260608_110128_8f10756f" - ], - "evolution_rewards": [ - 0.0 - ], - "evolution_costs": [ - 0.116874 - ], - "evolution_total_cost": 0.116874, - "evolution_avg_reward": 0.0, - "evolution_avg_cost": 0.116874 -} \ No newline at end of file diff --git a/run_notes/predict_bulk_modulus_rf.json b/run_notes/predict_bulk_modulus_rf.json deleted file mode 100644 index 472ad5f..0000000 --- a/run_notes/predict_bulk_modulus_rf.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "timestamp": "2026-06-07T16:41:43.177987", - "model": "openrouter/deepseek/deepseek-v4-flash", - "goal": "\nDOMAIN KNOWLEDGE:\n*On bulk modulus*: The bulk modulus measures a material's resistance to uniform compression. It is a critical mechanical property in materials science, indicating how incompressible a material is.\n*On features*: Features can be generated from material compositions and crystal structures using tools like Matminer. Features include composition descriptors such as electronegativity, and crystal structure descriptors such as coordination number, oxidation state, density, etc.\n\nINSTRUCTIONS:\nTrain a random forest model with the given dataset of inorganic crystalline compounds to predict their bulk modulus (K_VRH). Format the test set predictions as a two column dataframe, material_id and K_VRH, and save it to \"pred_results/compound_bulk_modulus.csv\".\n\nDATASET STRUCTURE:\n|-- crystalline_compound/\n|---- compound_elastic_properties_train.csv\n|---- compound_elastic_properties_test.csv\n\nDATASET PREVIEW:\n[START Preview of crystalline_compound/compound_elastic_properties_train.csv]\nmaterial_id,formula,space_group,structure,elastic_anisotropy,G_VRH,K_VRH,poisson_ratio\nmp-2705,VPt3,139,\"Full Formula (V2 Pt6)\nReduced Formula: VPt3\nabc : 3.892168 3.892168 7.942608\nangles: 90.000000 90.000000 90.000000\npbc : True True True\nSites (8)\n # SP a b c\n--- ---- --- --- ----\n 0 V 0 0 0\n 1 V 0.5 0.5 0.5\n 2 Pt 0 0.5 0.25\n 3 Pt 0.5 0 0.25\n 4 Pt 0.5 0.5 0\n 5 Pt 0.5 0 0.75\n 6 Pt 0 0.5 0.75\n 7 Pt 0 0 0.5\",0.585191191098,110.729547405,240.74394255400003,0.300597811521\nmp-922,CoPt3,221,\"Full Formula (Co1 Pt3)\nReduced Formula: CoPt3\nabc : 3.890389 3.890389 3.890389\nangles: 90.000000 90.000000 90.000000\npbc : True True True\nSites (4)\n # SP a b c\n--- ---- --- --- ---\n 0 Co 0 0 0\n 1 Pt 0 0.5 0.5\n 2 Pt 0.5 0.5 0\n 3 Pt 0.5 0 0.5\",0.34914991459,91.3885939807,220.629335072,0.318017855437\nmp-30490,Mg3Cd,194,\"Full Formula (Mg6 Cd2)\nReduced Formula: Mg3Cd\nabc : 6.313065 6.313066 5.091341\nangles: 90.000000 90.000000 120.000002\npbc : True True True\nSites (8)\n # SP a b c\n--- ---- -------- -------- ----\n 0 Mg 0.664434 0.832217 0.25\n 1 Mg 0.832217 0.167783 0.75\n 2 Mg 0.335566 0.167783 0.75\n 3 Mg 0.167783 0.832217 0.25\n 4 Mg 0.167783 0.335566 0.25\n 5 Mg 0.832217 0.664434 0.75\n 6 Cd 0.666667 0.333333 0.25\n 7 Cd 0.333333 0.666667 0.75\",0.0520508538327,20.9005568647,39.8842394193,0.276946960007\n...\n[END Preview of crystalline_compound/compound_elastic_properties_train.csv]\n\nEXPECTED OUTPUT:\n1. Results file — save to the EXACT path: pred_results/compound_bulk_modulus.csv\n (i.e. at the root of your workspace subfolder, not in any sub-directory)\n2. Python script — keep exactly ONE final script at: predict_bulk_modulus.py\n", - "execution_time_seconds": 660.3288373947144, - "analysis": "1. SUCCESS_LEVEL: High\n\n2. COMMENTS: \nThe multi-agent workflow successfully completed the assigned task. The `agent builder` correctly identified the need to avoid data leakage by excluding other elastic properties (G_VRH, poisson_ratio, elastic_anisotropy) and engineered compositional features from the chemical formulas. It trained a Random Forest model as requested and saved the predictions to the exact specified path with the correct columns. The `agent grounded_validator` rigorously verified the execution by re-running the script, checking for data leakage, and validating the output file's format, row count, and prediction distributions, ensuring physical reasonability.\n\n**Strengths:**\n- Strict adherence to formatting and file path constraints.\n- Proper handling of data leakage by excluding target-correlated features from the test/train split.\n- Thorough validation by the grounded validator, ensuring the output is non-degenerate and physically plausible.\n\n**Weaknesses & Areas for Improvement:**\n- **Ignored Domain Knowledge:** The domain knowledge explicitly suggested using tools like `Matminer` to generate features from compositions and crystal structures. The builder ignored this and wrote a custom regex-based formula parser. While functional for this specific dataset, custom regex parsers are brittle and fail on complex or non-standard chemical notations, making the approach less robust and scalable.\n- **Underutilized Data:** The dataset included a `structure` column containing full crystallographic data (lattice parameters, atomic sites). The domain knowledge highlighted that crystal structure descriptors (coordination number, density, etc.) are highly valuable. The builder completely ignored this rich structural data, relying solely on basic compositional statistics and space group, which likely left significant predictive performance on the table. \n- **Library Usage:** Relying on established materials science libraries (`pymatgen`, `matminer`) rather than reinventing the wheel with custom parsers would have yielded a much more scalable and scientifically rigorous feature engineering pipeline.", - "total_eval": 3, - "git": { - "commit": "01e640fe023e47e7d1f047d0eec9795e11de96a1", - "branch": "mimosa_v2", - "dirty": true - }, - "capsule_name": "predict_bulk_modulus_rf", - "ver_success": 2, - "sr_success": 1, - "avg_cbs": 0.9139301796497762, - "total_cost": 5.0090959392, - "is_success": true, - "task_cost": 0.5688743194999998, - "max_judge_reward": 1.0, - "evolution_iterations": 1, - "evolved_workflows_uuids": [ - "20260607_162717_ed88c10e" - ], - "evolution_rewards": [ - 1.0 - ], - "evolution_costs": [ - 0.5688743194999998 - ], - "evolution_total_cost": 0.5688743194999998, - "evolution_avg_reward": 1.0, - "evolution_avg_cost": 0.5688743194999998 -} \ No newline at end of file diff --git a/sources/core/evolution_engine.py b/sources/core/evolution_engine.py index 67f7eb6..ddb6254 100644 --- a/sources/core/evolution_engine.py +++ b/sources/core/evolution_engine.py @@ -158,7 +158,6 @@ def load_phenotype_result(self, uuid: str) -> Any: with open(f"{self.workflow_dir}/{uuid}/state_result.json") as f: return json.loads(f.read().strip()) except FileNotFoundError: - print(f"Workflow state for UUID {uuid} not found in {self.workflow_dir}.") return None except Exception as e: raise ValueError(f"❌ Error reading workflow state: {str(e)}") from e @@ -263,7 +262,6 @@ def select_parent_workflow( a flag telling the caller whether to apply crossover or mutation. """ if not os.path.exists(self.workflow_dir): - print(f"Workflow directory {self.workflow_dir} does not exist.") return [], False workflows = [ @@ -271,7 +269,6 @@ def select_parent_workflow( if os.path.isfile(os.path.join(self.workflow_dir, f, "state_result.json")) ] if not workflows: - print(f"No workflows found in {self.workflow_dir}.") return [], False # Explicit template → single parent, mutation only @@ -686,7 +683,6 @@ def _get_human_validation(self) -> bool: """ human_validation = input("Attempt to retry task? (yes/no): ").strip().lower() if human_validation not in ["yes", "y"]: - print("Exiting evolution loop.") return False return True diff --git a/sources/core/workflow_factory.py b/sources/core/workflow_factory.py index 4e5747c..a79fc98 100644 --- a/sources/core/workflow_factory.py +++ b/sources/core/workflow_factory.py @@ -493,7 +493,4 @@ async def craft_workflow( self.logger.debug(f"Workflow path: {workflow_path}") self.logger.debug(f"Memory path: {memory_path}") - print(f"Workflow generated with UUID: {uuid_str}") - print(complete_code) - return complete_code, workflow_genotype_code, uuid_str From 7eca670afbc066d1a1ad36aaf5bb6f49b8654190 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 11:33:45 +0200 Subject: [PATCH 12/71] logs: edit log style ; docs: edit docstring ; feat: don't evaluate on no wf executed true --- sources/core/evolution_engine.py | 10 ++++------ sources/core/selection.py | 10 ++-------- sources/core/workflow_factory.py | 4 ++-- sources/core/workflow_selection.py | 4 ++-- sources/evaluation/execution_sandbox.py | 4 ++-- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/sources/core/evolution_engine.py b/sources/core/evolution_engine.py index 67f7eb6..ea53500 100644 --- a/sources/core/evolution_engine.py +++ b/sources/core/evolution_engine.py @@ -1,5 +1,5 @@ """ -Darwinian Evolution of multi-agent workflows. +Neuroevolution-inspired, LLM driven evolution of Multi-Agents workflows. """ import json @@ -63,7 +63,7 @@ def _to_jsonable(obj: Any) -> Any: class EvolutionEngine: - """Darwin Machine for evolution of workflow workflows.""" + """Evolution Engine: Handle the evolution of Multi-agents workflows.""" def __init__( self, config: "Config", @@ -350,9 +350,7 @@ async def start_workflow_evolution( The list of :class:`IndividualRun` produced across the evolution. """ wf = None - max_iteration = 1 - if enable_evolution: - max_iteration = self.config.max_learning_evolve_iterations + max_iteration = self.config.max_learning_evolve_iterations if enable_evolution else 1 # Reset archive at session start self.selection._archive = [] @@ -726,7 +724,7 @@ async def _evaluate_and_calculate_cost( eval_type = None exec_cost = 0.0 - if judge and uuid: + if judge and uuid and executed: agent_answers = agent_answers if executed else "workflow failed to execute." eval_type = await self._evaluate_workflow_phenotype(uuid, agent_answers, scenario_rubric, assertion_history) # Calculate cost regardless of execution success diff --git a/sources/core/selection.py b/sources/core/selection.py index f60c546..63af6eb 100644 --- a/sources/core/selection.py +++ b/sources/core/selection.py @@ -162,19 +162,13 @@ def select_parent( In greedy mode: always returns the best-scoring run from `runs`. In tournament mode: probabilistic tournament among a random subset of `runs`. - In novelty/QD mode: samples a `PopulationMember` from `_archive` - biased toward high QD-score, falling back to greedy over `runs` - when the archive is empty (cold start). - Callers driving from archive must rehydrate the chosen member's - UUID into their domain object (e.g., WorkflowInfo). + In novelty/QD mode: samples a `PopulationMember` from `_archive` biased toward high QD-score Args: runs: Candidate pool. May be a list of `PopulationMember` (archive draw) or any object with a `reward` attribute (greedy/tournament). child_counts: Optional ``{uuid: n_children_already}`` map used in - QD/novelty mode to apply a ``1/(1+n_children)`` penalty so - already-mined parents don't keep dominating the offspring stream. - v2_evolution §7 leveraged-move #4. + QD/novelty mode to apply a ``1/(1+n_children)`` penalty Returns: The chosen parent (a run object or a ``PopulationMember``), or diff --git a/sources/core/workflow_factory.py b/sources/core/workflow_factory.py index 4e5747c..e934d32 100644 --- a/sources/core/workflow_factory.py +++ b/sources/core/workflow_factory.py @@ -153,7 +153,7 @@ def llm_make_workflow( model=model, provider=provider, reasoning_effort=self.config.reasoning_effort, - max_tokens=16384, + max_tokens=32000, openrouter_provider=None, # use default ) return LLMProvider("workflow_creator", path, system_prompt, llm_config)(prompt, use_cache=allow_cache) @@ -275,7 +275,7 @@ def validate_workflow_structure(self, workflow_genotype_code: str) -> None: raise ValueError(f"START targets non-existent node '{entry_node}'") self.logger.debug(f"Workflow entry point: START → {entry_node}") - self.logger.info("✅ Workflow structure validation passed") + self.logger.info("Workflow structure validation passed") def assemble_workflow( self, diff --git a/sources/core/workflow_selection.py b/sources/core/workflow_selection.py index 051c95c..07666a2 100644 --- a/sources/core/workflow_selection.py +++ b/sources/core/workflow_selection.py @@ -296,7 +296,7 @@ def select_parent_workflows( scores = [f"{wf.overall_score:.2f}" for wf in selected_workflows] mode = "CROSSOVER" if use_crossover else "MUTATION" logger.info( - f"🧬 Archive selection ({mode}, strategy={selection_pressure.strategy.value}): " + f"Archive selection ({mode}, strategy={selection_pressure.strategy.value}): " f"{len(selected_workflows)} parent(s) from archive size={len(selection_pressure._archive)} " f"— UUIDs={uuids}, scores={scores}" ) @@ -326,7 +326,7 @@ def select_parent_workflows( scores = [f"{wf.overall_score:.2f}" for wf in selected_workflows] mode = "CROSSOVER" if use_crossover else "MUTATION" logger.info( - f"🧬 Cold-start selection ({mode}, strategy={selection_pressure.strategy.value}): " + f"Cold-start selection ({mode}, strategy={selection_pressure.strategy.value}): " f"{len(selected_workflows)} parent(s) from {len(candidates)} disk candidates " f"— UUIDs={uuids}, scores={scores}" ) diff --git a/sources/evaluation/execution_sandbox.py b/sources/evaluation/execution_sandbox.py index 8ce5ec8..8271f43 100644 --- a/sources/evaluation/execution_sandbox.py +++ b/sources/evaluation/execution_sandbox.py @@ -511,10 +511,10 @@ def _copy_capsule_contents_to_temp(self, temp_path: Path) -> None: continue if copied_files: for name, size in copied_files: - self.logger.info(f" 📄 {name:<30} {size:>12,} bytes") + self.logger.info(f" {name:<30} {size:>12,} bytes") if copied_dirs: for name, count in copied_dirs: - self.logger.info(f" 📁 {name}/ ({count} nested files)") + self.logger.info(f"{name}/ ({count} nested files)") if errors: self.logger.warning(f"[SANDBOX] Failed items ({len(errors)}):") for name, err in errors: From a0f9f9d7a209eab4c17ec5fd6bb4a84a3a495a86 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 11:57:41 +0200 Subject: [PATCH 13/71] feat(workflow_factory,llm_provider): pick random workflow temperature, fall back to 1.0 if provider rejects it --- sources/core/llm_provider.py | 19 +++++++++++++++++-- sources/core/workflow_factory.py | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sources/core/llm_provider.py b/sources/core/llm_provider.py index 9bd052e..7ad0ce1 100644 --- a/sources/core/llm_provider.py +++ b/sources/core/llm_provider.py @@ -315,6 +315,11 @@ def _messages_match(self, expected: list[dict[str, Any]], cached: list[dict[str, return True + @staticmethod + def _is_temperature_error(error: Exception) -> bool: + """True when the API rejected ``temperature``, read from ``error.param``.""" + return getattr(error, "param", None) == "temperature" + def _is_retryable_error(self, error: Exception) -> bool: """Check if an error is retryable (temporary/transient). @@ -399,15 +404,17 @@ def __call__(self, prompt: str, timeout: int = 180, use_cache: bool = True) -> s attempt = 0 max_wait = 500 # Maximum wait time in seconds context_window_retry_count = 0 # Track context window errors specifically + effective_temperature = self.config.temperature while True: # Infinite retry loop try: completion_params = { "model": f"{self.config.provider}/{self.config.model}", "messages": message, - "temperature": self.config.temperature, + "temperature": effective_temperature, "timeout": timeout, "max_tokens": self.config.max_tokens, + "drop_params": True, } completion_params["api_key"] = self.config.key # Add reasoning effort if supported (not for Claude models) @@ -447,6 +454,14 @@ def __call__(self, prompt: str, timeout: int = 180, use_cache: bool = True) -> s attempt += 1 except Exception as e: + if self._is_temperature_error(e) and effective_temperature != 1.0: + self.logger.warning( + f"Provider rejected temperature={effective_temperature:.2f}; " + f"falling back to 1.0 and retrying." + ) + effective_temperature = 1.0 + continue + # Check if this is a retryable error if self._is_retryable_error(e): error_type = type(e).__name__.lower() @@ -506,7 +521,7 @@ def __call__(self, prompt: str, timeout: int = 180, use_cache: bool = True) -> s **response.json(), "response": res, "message": message, - "temperature": self.config.temperature, + "temperature": effective_temperature, "reasoning_effort": self.config.reasoning_effort if not self._is_claude_model() else None, "model": f"{self.config.provider}/{self.config.model}", # Ensure consistent model format for pricing } diff --git a/sources/core/workflow_factory.py b/sources/core/workflow_factory.py index a79fc98..5065d5e 100644 --- a/sources/core/workflow_factory.py +++ b/sources/core/workflow_factory.py @@ -4,6 +4,7 @@ import logging import os +import random import re import time import uuid @@ -149,9 +150,12 @@ def llm_make_workflow( """ provider, model = extract_model_pattern(self.config.workflow_llm_model) + temperature = random.uniform(0.7, 1.3) + self.logger.info(f"Workflow LLM temperature: {temperature:.2f}") llm_config = LLMConfig( model=model, provider=provider, + temperature=temperature, reasoning_effort=self.config.reasoning_effort, max_tokens=16384, openrouter_provider=None, # use default From 94a81feee8d9f1ce0fd632d484eeb06fa62d1159 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 12:16:20 +0200 Subject: [PATCH 14/71] feat : reduce temp for verifier --- sources/core/evaluators/base.py | 1 + sources/core/workflow_factory.py | 10 +--------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/sources/core/evaluators/base.py b/sources/core/evaluators/base.py index e7a8c19..7f03709 100644 --- a/sources/core/evaluators/base.py +++ b/sources/core/evaluators/base.py @@ -128,6 +128,7 @@ def __init__(self, config: "Config") -> None: self.llm_config = LLMConfig().from_dict({ "model": model, "provider": provider, + "temperature": 0.2, "reasoning_effort": config.reasoning_effort, "max_tokens": getattr(config, 'max_tokens', 8192), "openrouter_provider": config.openrouter_provider_for(self.judge_model), diff --git a/sources/core/workflow_factory.py b/sources/core/workflow_factory.py index 3df9eda..dfdc744 100644 --- a/sources/core/workflow_factory.py +++ b/sources/core/workflow_factory.py @@ -90,10 +90,6 @@ def extract_python_code(code: str) -> str: def remove_imports(self, code: str) -> str: """Strip ``import``/``from ... import`` lines from LLM-generated code. - The generated workflow body is concatenated with a host script that - already provides the standard imports, so any LLM-emitted imports are - stripped here to avoid duplication and unauthorised modules. - Args: code: Source code potentially containing import statements. @@ -120,10 +116,6 @@ def llm_make_workflow( ) -> str: """Ask the LLM to generate a workflow body. - Builds the user prompt from ``craft_instructions`` and - ``existing_tool_prompt`` and dispatches a single LLM call via - :class:`LLMProvider`. - Args: system_prompt: System prompt that steers the workflow-creator LLM. craft_instructions: User-level instructions / goal for the workflow. @@ -150,7 +142,7 @@ def llm_make_workflow( """ provider, model = extract_model_pattern(self.config.workflow_llm_model) - temperature = random.uniform(0.7, 1.3) + temperature = random.uniform(0.7, 1.3) # enhance workflow diversity and avoid error repetition. self.logger.info(f"Workflow LLM temperature: {temperature:.2f}") llm_config = LLMConfig( model=model, From ac38c57184560ab0504d5b848ab5d0c6ce748bcc Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 12:34:17 +0200 Subject: [PATCH 15/71] docs: ensure 3.12 runner specification --- config.py | 6 +++--- docs/getting-started/configuration.md | 2 +- docs/reference/configuration.md | 2 +- docs/site/reference/configuration/index.html | 2 +- sources/core/workflow_runner.py | 19 ++++++------------- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/config.py b/config.py index bcde659..4fb4448 100644 --- a/config.py +++ b/config.py @@ -38,11 +38,11 @@ class Config: def __init__(self): # workspace configuration - self.workspace_dir = "/home/martin/Projects/CNRS/Toolomics/workspace_2" + self.workspace_dir = "/Users/mlg/Documents/CNRS/toolomics/workspace" # MCPs server discovery self.discovery_addresses: list[AddressMCP] = [ - AddressMCP(ip="0.0.0.0", port_min=5101, port_max=5200) + AddressMCP(ip="0.0.0.0", port_min=5000, port_max=5200) ] # LLMs choices @@ -97,7 +97,7 @@ def __init__(self): self.openrouter_quantizations_by_model: dict[str, list[str] | None] = {} self.default_openrouter_quantizations: list[str] = ["bf16", "fp16", "fp8"] # runner settings - self.runner_default_python_version: str = "3.10" + self.runner_default_python_version: str = "3.12" self.runner_default_timeout: int = 10800 # Per-agent (SmolAgentFactory) execution timeout in seconds. Injected into # the generated workflow as AGENT_EXECUTION_TIMEOUT. 3600 = 1 hour. diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index f2be639..39baf98 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -78,7 +78,7 @@ The sandbox enforces resource caps per generated workflow: | Field | Default | Purpose | | ----- | ------- | ------- | -| `runner_default_python_version` | `3.10` | Python version inside the sandbox. | +| `runner_default_python_version` | `3.12` | Python version inside the sandbox. | | `runner_default_timeout` | `3600` | Per-run timeout (seconds). | | `runner_default_max_memory_mb` | `1024` | RAM cap (MB). | | `runner_default_max_cpu_percent` | `100` | CPU cap (%). | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 68324a6..ce7ca7c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -70,7 +70,7 @@ See [Troubleshooting → OpenRouter quantization](troubleshooting.md#openrouter- | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | -| `runner_default_python_version` | `str` | `3.10` | Python in the sandbox. | +| `runner_default_python_version` | `str` | `3.12` | Python in the sandbox. | | `runner_default_timeout` | `int` | `3600` | Per-run timeout (s). | | `runner_default_max_memory_mb` | `int` | `1024` | RAM cap (MB). | | `runner_default_max_cpu_percent` | `int` | `100` | CPU cap (%). | diff --git a/docs/site/reference/configuration/index.html b/docs/site/reference/configuration/index.html index 45d355d..d432a97 100644 --- a/docs/site/reference/configuration/index.html +++ b/docs/site/reference/configuration/index.html @@ -2450,7 +2450,7 @@

Sandbox runner list[str] | None: pass return None - version = self.config.python_version # e.g. "3.10" + version = self.config.python_version # e.g. "3.12" if sys.platform == "win32": candidates: list[tuple[bool, list[str]]] = [ @@ -610,7 +603,7 @@ async def cleanup(self) -> None: async def main() -> None: """Example usage of the WorkflowRunner.""" - config = RuntimeConfig(python_version="3.10", timeout=60, max_memory_mb=256) + config = RuntimeConfig(python_version="3.12", timeout=60, max_memory_mb=256) runner = WorkflowRunner(config) await runner.install_dependencies(["requests", "numpy"]) code = """ From f1bdcdd9a4012ccc392c1738e48a37727c801502 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 14:13:14 +0200 Subject: [PATCH 16/71] feat(workflow_runner): install workflow deps into a managed venv instead of system Python --- sources/core/workflow_runner.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/sources/core/workflow_runner.py b/sources/core/workflow_runner.py index 8df9929..3a072cd 100644 --- a/sources/core/workflow_runner.py +++ b/sources/core/workflow_runner.py @@ -228,6 +228,28 @@ def _check_python_version(self) -> bool: return True return False + def _ensure_venv(self) -> None: + """Create a managed venv under ``temp_dir`` and use it for installs/execs. + + No-op when the caller pinned ``python_executable`` (the verifier path + depends on running under ``sys.executable``). Idempotent — reuses an + existing venv at the same path. After this call ``self._python_cmd`` + points at the venv's interpreter. + """ + import subprocess + if self.config.python_executable: + return + venv_dir = Path(self.config.temp_dir) / "mimosa_venv" + bin_dir = "Scripts" if sys.platform == "win32" else "bin" + venv_python = venv_dir / bin_dir / ("python.exe" if sys.platform == "win32" else "python") + if not venv_python.exists(): + subprocess.run( + [*self._python_cmd, "-m", "venv", str(venv_dir)], + check=True, capture_output=True, timeout=120, + ) + self.logger.info(f"Created venv at {venv_dir}") + self._python_cmd = [str(venv_python)] + async def ensure_pip(self) -> None: """Ensure pip is installed and up-to-date.""" import subprocess @@ -277,6 +299,7 @@ async def install_dependencies( if not requirements and not self.config.requirements_file: return ExecutionResult(ExecutionStatus.COMPLETED, 0, "", "", 0.0) + self._ensure_venv() await self.ensure_pip() cmd = [*self._python_cmd, "-m", "pip", "install"] From 5f47917016cca4e67e39ba31f467716888ec8d35 Mon Sep 17 00:00:00 2001 From: martin Date: Sat, 13 Jun 2026 12:35:45 +0200 Subject: [PATCH 17/71] feat(evolution_tree): render per-goal trees into workflow folders Co-Authored-By: Claude Opus 4.7 --- sources/core/evolution_engine.py | 23 ++++++-- sources/core/lineage.py | 20 ++++++- sources/utils/evolution_tree.py | 6 ++- tests/lineage_goal_filter_test.py | 88 +++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/lineage_goal_filter_test.py diff --git a/sources/core/evolution_engine.py b/sources/core/evolution_engine.py index 084878e..1ff18f9 100644 --- a/sources/core/evolution_engine.py +++ b/sources/core/evolution_engine.py @@ -542,7 +542,7 @@ async def evolve_generation( rewards_history, assertion_history, runs[-1].goal, runs[-1].scenario_rubric, uuid ) - self._refresh_evolution_tree() + self._refresh_evolution_tree(runs[-1].goal, uuid) # Calculate cumulative cost and update runs[-1].cost for accurate tracking runs[-1].cost = runs[-1].cost + current_iteration_cost @@ -883,16 +883,31 @@ def _log_iteration_completion( title=f"Workflow {uuid} completed.", ) - def _refresh_evolution_tree(self) -> None: - """Re-render the evolution-tree PNG after each iteration. + def _refresh_evolution_tree( + self, goal: str | None = None, uuid: str | None = None + ) -> None: + """Re-render the goal-specific evolution-tree PNG after each iteration. + + Scans only workflows whose ``goal_.txt`` matches *goal* so trees + from different runs no longer pile into one root-level image, and writes + the result to ``//evolution_tree.png``. Best-effort: scanning failures are logged and swallowed so an issue rendering the tree never aborts an evolution run. The visualizer is imported lazily to avoid a circular import via ``sources.core``. """ + if not uuid: + return + workflow_path = Path(self.workflow_dir) / uuid + if not workflow_path.is_dir(): + return try: from sources.utils.evolution_tree import render_evolution_tree - output = render_evolution_tree(self.workflow_dir) + output = render_evolution_tree( + self.workflow_dir, + output_path=workflow_path / "evolution_tree.png", + goal=goal, + ) if output is not None: self.logger.info(f"Evolution tree refreshed: {output}") except Exception as e: diff --git a/sources/core/lineage.py b/sources/core/lineage.py index e6e0788..4cb7def 100644 --- a/sources/core/lineage.py +++ b/sources/core/lineage.py @@ -157,7 +157,18 @@ def find_oldest_rubric_anchor( return candidate -def scan_all(workflow_dir: str | Path) -> dict[str, dict]: +def _read_workflow_goal(folder: Path, uuid: str) -> str | None: + """Return the trimmed contents of ``goal_.txt``, or None if absent/unreadable.""" + target = folder / f"goal_{uuid}.txt" + try: + return target.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return None + + +def scan_all( + workflow_dir: str | Path, goal: str | None = None +) -> dict[str, dict]: """Enumerate every workflow folder and return ``{uuid: lineage_or_synthetic}``. Workflows without a ``lineage_*.json`` are synthesised as orphan seeds so @@ -167,6 +178,10 @@ def scan_all(workflow_dir: str | Path) -> dict[str, dict]: Args: workflow_dir: Project's workflow directory to scan. + goal: When provided, only workflows whose ``goal_.txt`` matches + this text (after trimming) are returned. Folders missing the goal + file are excluded. This keeps a single run's tree from mixing with + other goals' workflows. ``None`` scans everything. Returns: Dictionary mapping each workflow UUID to its lineage record. Records @@ -177,11 +192,14 @@ def scan_all(workflow_dir: str | Path) -> dict[str, dict]: if not root.is_dir(): return {} + goal_filter = goal.strip() if goal is not None else None records: dict[str, dict] = {} for entry in sorted(os.listdir(root)): folder = root / entry if not folder.is_dir() or entry.startswith("_") or entry.startswith("."): continue + if goal_filter is not None and _read_workflow_goal(folder, entry) != goal_filter: + continue rec = load_lineage(root, entry) if rec is None: # Synthesize a minimal record so legacy workflows still appear. diff --git a/sources/utils/evolution_tree.py b/sources/utils/evolution_tree.py index 0e9d017..ab85d63 100644 --- a/sources/utils/evolution_tree.py +++ b/sources/utils/evolution_tree.py @@ -130,6 +130,7 @@ def render_evolution_tree( workflow_dir: str | Path, output_path: str | Path | None = None, title: str | None = None, + goal: str | None = None, ) -> Path | None: """Scan ``workflow_dir`` and render the evolution tree to PNG. @@ -138,12 +139,15 @@ def render_evolution_tree( output_path: Where to write the PNG. Defaults to ``/evolution_tree.png``. title: Optional plot title; defaults to a generic header. + goal: When provided, restrict the tree to workflows whose + ``goal_.txt`` matches this text, so a single run's tree isn't + polluted by other goals' workflows. Returns: Path to the written PNG, or None if no workflows were found. """ root = Path(workflow_dir) - records = scan_all(root) + records = scan_all(root, goal=goal) if not records: logger.info(f"evolution_tree: no workflows under {root}") return None diff --git a/tests/lineage_goal_filter_test.py b/tests/lineage_goal_filter_test.py new file mode 100644 index 0000000..d3009a0 --- /dev/null +++ b/tests/lineage_goal_filter_test.py @@ -0,0 +1,88 @@ +"""Tests for ``lineage.scan_all`` goal filtering. + +Builds workflow folders carrying ``goal_.txt`` + ``lineage_.json`` +sidecars and confirms ``scan_all(goal=...)`` returns only the workflows whose +goal text matches — the mechanism that keeps each run's evolution tree from +mixing with workflows produced for other goals. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).parent.parent +sys.path.append(str(_REPO_ROOT)) + +# Import lineage directly; sources.core.__init__ pulls in heavy deps +# (sentence_transformers, etc.) that aren't needed here. +_spec = importlib.util.spec_from_file_location( + "lineage_mod", _REPO_ROOT / "sources" / "core" / "lineage.py" +) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +scan_all = _mod.scan_all + + +def _write_workflow(workflow_dir: Path, uuid: str, goal: str | None) -> None: + """Create ``/`` with a lineage sidecar and optional ``goal_.txt``.""" + folder = workflow_dir / uuid + folder.mkdir(parents=True, exist_ok=True) + (folder / f"lineage_{uuid}.json").write_text( + json.dumps({ + "uuid": uuid, + "parents": [], + "evolution_kind": "seed", + "iteration": 0, + "created_at": "2026-06-13T12:00:00", + "goal_snippet": "", + }), + encoding="utf-8", + ) + if goal is not None: + (folder / f"goal_{uuid}.txt").write_text(goal, encoding="utf-8") + + +def test_no_goal_returns_all(tmp_path: Path) -> None: + """Without a goal filter every workflow folder is returned (backward compat).""" + _write_workflow(tmp_path, "a", goal="Goal A") + _write_workflow(tmp_path, "b", goal="Goal B") + assert set(scan_all(tmp_path)) == {"a", "b"} + + +def test_goal_filter_selects_matching_only(tmp_path: Path) -> None: + """Only workflows whose goal_.txt matches the goal are returned.""" + _write_workflow(tmp_path, "a1", goal="Goal A") + _write_workflow(tmp_path, "a2", goal="Goal A") + _write_workflow(tmp_path, "b1", goal="Goal B") + assert set(scan_all(tmp_path, goal="Goal A")) == {"a1", "a2"} + assert set(scan_all(tmp_path, goal="Goal B")) == {"b1"} + + +def test_goal_filter_is_whitespace_tolerant(tmp_path: Path) -> None: + """Trailing/leading whitespace in either side does not break the match.""" + _write_workflow(tmp_path, "a", goal="Goal A\n") + assert set(scan_all(tmp_path, goal=" Goal A ")) == {"a"} + + +def test_missing_goal_file_excluded_when_filtering(tmp_path: Path) -> None: + """A folder without goal_.txt is excluded once a goal filter is set.""" + _write_workflow(tmp_path, "has_goal", goal="Goal A") + _write_workflow(tmp_path, "no_goal", goal=None) + assert set(scan_all(tmp_path, goal="Goal A")) == {"has_goal"} + # ...but still appears with no filter. + assert set(scan_all(tmp_path)) == {"has_goal", "no_goal"} + + +def test_nonexistent_dir_returns_empty(tmp_path: Path) -> None: + """Scanning a missing directory yields an empty mapping regardless of goal.""" + assert scan_all(tmp_path / "missing", goal="Goal A") == {} + + +if __name__ == "__main__": + import tempfile + with tempfile.TemporaryDirectory() as d: + test_goal_filter_selects_matching_only(Path(d)) + print("lineage_goal_filter_test: smoke ok") From c9082a98ae0f9e580cf12c2ced54aaaa9a850f26 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 15:40:03 +0200 Subject: [PATCH 18/71] feat(smolagent): additional_authorized_imports change --- sources/core/single_agent_factory.py | 2 +- sources/modules/smolagent_factory.py | 2 +- sources/prompts/smolagent_sys_prompt.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sources/core/single_agent_factory.py b/sources/core/single_agent_factory.py index 48450af..50f3aa9 100644 --- a/sources/core/single_agent_factory.py +++ b/sources/core/single_agent_factory.py @@ -174,7 +174,7 @@ async def craft_single_agent(self, goal: str, original_task: str | None = None) name="single_agent", max_steps=256, additional_authorized_imports = [ - 'requests', 'bs4', 'json', 'requests.exceptions', + 'requests', 'json', 'requests.exceptions', 'os', 'sys', 'pathlib', 'shutil', 'glob', 'tempfile', 'argparse', 'configparser', 'logging', 'collections', 'itertools', 'functools', 'heapq', 'bisect', 'queue', diff --git a/sources/modules/smolagent_factory.py b/sources/modules/smolagent_factory.py index 70f479d..ede3e5c 100644 --- a/sources/modules/smolagent_factory.py +++ b/sources/modules/smolagent_factory.py @@ -101,7 +101,7 @@ def __init__(self, max_steps=max_steps, #planning_interval=planning_interval, # think more before acting additional_authorized_imports = [ - 'requests', 'bs4', 'json', 'requests.exceptions', + 'requests', 'json', 'requests.exceptions', # Core Utilities 'os', 'sys', 'pathlib', 'shutil', 'glob', 'tempfile', 'argparse', 'configparser', 'logging', diff --git a/sources/prompts/smolagent_sys_prompt.md b/sources/prompts/smolagent_sys_prompt.md index 9104cf3..863b07f 100644 --- a/sources/prompts/smolagent_sys_prompt.md +++ b/sources/prompts/smolagent_sys_prompt.md @@ -91,7 +91,7 @@ print(df) **Authorized import in code_block tag** [ - 'requests', 'bs4', 'json', 'requests.exceptions', + 'requests', 'json', 'requests.exceptions', # Core Utilities 'os', 'sys', 'pathlib', 'shutil', 'glob', 'tempfile', 'argparse', 'configparser', 'logging', From 5f936b8e2553f6951664f770eb80b194e5b50a41 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 16:01:00 +0200 Subject: [PATCH 19/71] fix: reduce crossover rate --- config.py | 2 +- docs/diagrams/evolution_loop.mermaid | 2 +- docs/site/diagrams/evolution_loop.mermaid | 2 +- sources/core/evolution_engine.py | 2 +- sources/core/selection.py | 2 +- sources/core/workflow_selection.py | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config.py b/config.py index 4fb4448..9ea52cc 100644 --- a/config.py +++ b/config.py @@ -48,7 +48,7 @@ def __init__(self): # LLMs choices self.planner_llm_model: str = "openrouter/z-ai/glm-5.1" self.workflow_llm_model: str = "openrouter/z-ai/glm-5.1" - self.smolagent_model_id: str = "openrouter/deepseek/deepseek-v4-pro" + self.smolagent_model_id: str = "openrouter/moonshotai/kimi-k2.7-code" self.judge_model = "openrouter/qwen/qwen3.7-max" self.capsule_namer_model = "openrouter/deepseek/deepseek-v4-flash" self.engine_name: str = "litellm" # for smolagent diff --git a/docs/diagrams/evolution_loop.mermaid b/docs/diagrams/evolution_loop.mermaid index 6152222..92e05d5 100644 --- a/docs/diagrams/evolution_loop.mermaid +++ b/docs/diagrams/evolution_loop.mermaid @@ -27,7 +27,7 @@ flowchart TB ParentDraw{{"archive populated?"}} DrawArch["select_parent_workflows()
QD-roulette over archive
÷ (1 + n_children_already)"] DrawDisk["cold-start fallback:
similarity-filtered disk scan"] - Decide{{"crossover_rate ≈ 0.4?
(only once initial_population met)"}} + Decide{{"crossover_rate ≈ 0.1?
(only once initial_population met)"}} Mut["mutation_prompt()
evidence-driven scope (Rechenberg 1/5)
raw_stagnation × success-rate gating"] Cross["crossover_prompt()
best-parent-first ordering
rubric-blind diagnosis"] diff --git a/docs/site/diagrams/evolution_loop.mermaid b/docs/site/diagrams/evolution_loop.mermaid index 7ac8570..55e0965 100644 --- a/docs/site/diagrams/evolution_loop.mermaid +++ b/docs/site/diagrams/evolution_loop.mermaid @@ -27,7 +27,7 @@ flowchart TB ParentDraw{{"archive populated?"}} DrawArch["select_parent_workflows()
QD-roulette over archive
÷ (1 + n_children_already)"] DrawDisk["cold-start fallback:
similarity-filtered disk scan"] - Decide{{"crossover_rate ≈ 0.3?"}} + Decide{{"crossover_rate ≈ 0.1?"}} Mut["mutation_prompt()
phase-aware annealing schedule
SEED → ANCHOR → DECOMPOSE → ENGAGE → POLISH"] Cross["crossover_prompt()
best-parent-first ordering
rubric-blind diagnosis"] diff --git a/sources/core/evolution_engine.py b/sources/core/evolution_engine.py index 1ff18f9..ed49964 100644 --- a/sources/core/evolution_engine.py +++ b/sources/core/evolution_engine.py @@ -244,7 +244,7 @@ def select_parent_workflow( self, goal: str, template_uuid: str | None = None, - crossover_rate: float = 0.4, + crossover_rate: float = 0.1, n_parents: int = 2, ) -> tuple[list[WorkflowInfo], bool]: """Select one or more parent workflows under evolutionary pressure. diff --git a/sources/core/selection.py b/sources/core/selection.py index 63af6eb..f06e6f9 100644 --- a/sources/core/selection.py +++ b/sources/core/selection.py @@ -211,7 +211,7 @@ def select_parents( self, candidates: list[Any], n_parents: int = 2, - crossover_rate: float = 0.3, + crossover_rate: float = 0.1, child_counts: dict[str, int] | None = None, ) -> tuple[list[Any], bool]: """Select one or more parents from a candidate pool. diff --git a/sources/core/workflow_selection.py b/sources/core/workflow_selection.py index 07666a2..692bdc9 100644 --- a/sources/core/workflow_selection.py +++ b/sources/core/workflow_selection.py @@ -263,7 +263,7 @@ def select_parent_workflows( goal: str, selection_pressure: SelectionPressure, n_parents: int = 2, - crossover_rate: float = 0.3, + crossover_rate: float = 0.1, threshold_similarity: float = 0.8, threshold_score: float = 0.1, ) -> tuple[list[WorkflowInfo], bool]: @@ -349,7 +349,7 @@ def select_parent_workflows( goal=goal, selection_pressure=sp, n_parents=2, - crossover_rate=0.5, + crossover_rate=0.1, ) mode = "CROSSOVER" if crossover else "MUTATION" print(f" Mode: {mode}, Parents: {len(selected)}") From 6807c2ed63c78d8f96b9d53a18ea6146f15e1274 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 16:16:54 +0200 Subject: [PATCH 20/71] refactor(variation_engine): replace embedding stagnation with plateau counter --- sources/core/variation_engine.py | 255 ++++++++++++++----------------- tests/variation_engine_test.py | 201 +++++++++++++----------- 2 files changed, 230 insertions(+), 226 deletions(-) diff --git a/sources/core/variation_engine.py b/sources/core/variation_engine.py index ed4c0ab..bab53a8 100644 --- a/sources/core/variation_engine.py +++ b/sources/core/variation_engine.py @@ -4,8 +4,6 @@ """ import math -from sentence_transformers import SentenceTransformer -import torch.nn.functional as F from .workflow_info import WorkflowInfo from sources.cli.pretty_print import ( @@ -16,23 +14,16 @@ import numpy as np class VariationEngine: - """ - Orchestrates iterative LLM-driven workflow search via structured prompt mutation. - Each call to mutation_prompt() or crossover_prompt() produces a prompt that: - - Anchors the LLM on concrete execution feedback (agent answers, judge eval). - - Applies a stagnation-driven mutation scope that widens exploration - breadth and grows the agent budget as recent offspring keep failing - the same way, damped by parent score so near-winners stay protected. - """ + """Assemble mutation/crossover prompts and pick mutation scope from + Rechenberg 1/5 success rate and a non-improvement plateau counter.""" def __init__(self) -> None: - """Initialise empty history buffers and lazy embedder state.""" + """Initialise empty history buffers.""" self.textual_gradient_history: list[tuple[str, bool]] = [] # Per-offspring (child_score, best_before, is_failure) for the Rechenberg 1/5 success rule. self.score_history: list[tuple[float | None, float | None, bool]] = [] self.agent_count_history: list[int] = [] self.max_possible_agents = 7 - self._embedder: SentenceTransformer | None = None self.last_variation_state: dict = {} def record_offspring_gradient( @@ -49,7 +40,7 @@ def record_offspring_gradient( gradient: Free-text diagnosis of the offspring's failure mode. Empty values are replaced with a sentinel placeholder. is_failure: Whether the offspring failed to execute at all - (excluded from semantic stagnation and success-rate stats). + (excluded from plateau-counter and success-rate stats). child_score: Overall score of the produced offspring, in ``[0, 1]``. ``None`` when unavailable; such entries do not contribute to the success-rate signal. @@ -62,12 +53,11 @@ def record_offspring_gradient( self.textual_gradient_history.append((text, bool(is_failure))) self.score_history.append((child_score, best_before, bool(is_failure))) - def _sample_agent_count(self, stagnation: float, lo: int, hi: int, concentration: float = 4.0) -> int: - """Sample a random agent count within ``[lo, hi]``, biased upward by stagnation. + def _sample_agent_count(self, boldness: float, lo: int, hi: int, concentration: float = 4.0) -> int: + """Sample a random agent count within ``[lo, hi]``, biased upward by boldness. Args: - stagnation: Stagnation level in ``[0, 1]`` pulling the mean toward - ``hi``. + boldness: Boldness level in ``[0, 1]`` pulling the mean toward ``hi``. lo: Inclusive lower bound on the agent count. hi: Inclusive upper bound on the agent count. concentration: Beta concentration parameter; higher values @@ -78,59 +68,32 @@ def _sample_agent_count(self, stagnation: float, lo: int, hi: int, concentration """ if lo == hi: return lo - target_mean = lo + stagnation * (hi - lo) + target_mean = lo + boldness * (hi - lo) p = np.clip((target_mean - lo) / (hi - lo), 0.05, 0.95) alpha = p * concentration beta = (1 - p) * concentration prob = np.random.beta(alpha, beta) return lo + int(np.random.binomial(hi - lo, prob)) - def _textual_gradient_similarity(self, a: str, b: str) -> float: - """Cosine similarity over MiniLM-encoded diagnoses. + def _iters_since_improvement(self) -> int: + """Length of the current run of scored offspring that did not beat best-so-far. - Args: - a: First diagnosis text. - b: Second diagnosis text. - - Returns: - Cosine similarity in ``[-1, 1]``, or ``0.0`` when either text is empty. - """ - if not a or not b: - return 0.0 - if self._embedder is None: - self._embedder = SentenceTransformer("all-MiniLM-L6-v2", token=False) - emb_a = self._embedder.encode(a, convert_to_tensor=True, show_progress_bar=False) - emb_b = self._embedder.encode(b, convert_to_tensor=True, show_progress_bar=False) - return F.cosine_similarity(emb_a, emb_b, dim=0).item() - - def _compute_stagnation(self, window: int = 10) -> float: - """Mean pairwise cosine over recent non-failure offspring gradients, ∈ [0, 1]. - - Args: - window: How many recent semantic gradients to consider. - - Returns: - Stagnation in ``[0, 1]``; ``0.0`` when fewer than two non-failure - gradients are available. + Failures and ``None``-scored entries are skipped (no count, no break). + Returns ``0`` when the most recent scored offspring improved. """ - semantic = [g for g, is_failure in self.textual_gradient_history if not is_failure] - recent = semantic[-window:] - if len(recent) < 2: - return 0.0 - sims = [ - self._textual_gradient_similarity(recent[i], recent[j]) - for i in range(len(recent)) - for j in range(i + 1, len(recent)) - ] - raw = sum(sims) / len(sims) if sims else 0.0 - # MiniLM unrelated baseline ≈ 0.4; treat 0.8+ as fully stagnated. - return float(np.clip((raw - 0.4) / 0.4, 0, 1)) - - # The Rechenberg success threshold — fraction of recent offspring that - # must improve on the best-so-far for the search to be considered - # "making progress". Below this, step size is grown; above, damped. - # 0.20 is the classical 1/5 success rule (Rechenberg 1973). - _SUCCESS_RULE_THRESHOLD = 0.20 + count = 0 + for c, b, is_failure in reversed(self.score_history): + if is_failure or c is None or b is None: + continue + if c > b + 1e-6: + break + count += 1 + return count + + _SUCCESS_RULE_THRESHOLD = 0.20 # Classical Rechenberg 1/5 rule. + _PLATEAU_PATIENCE = 6 + _RESPECIATION_PATIENCE = 8 + _RESPECIATION_CLAMP = 0.89 # Just below the 0.90 RE-SPECIATION band. def _compute_success_rate(self, window: int = 5) -> float | None: """Fraction of recent scored offspring that improved on best-so-far. @@ -162,63 +125,49 @@ def _compute_success_rate(self, window: int = 5) -> float | None: return sum(1 for c, b in recent if c > b + 1e-6) / len(recent) def _get_prompt_step_size(self, parent_score: float = 0.0) -> str: - """Evidence-based mutation scope (Rechenberg 1/5 success rule, 1973). - - Boldness is driven by two evidence signals, not by the parent's - absolute score: - - - ``raw_stagnation`` — cosine similarity of recent textual gradients. - High → the search keeps diagnosing the same failure. - - ``success_rate`` — fraction of recent offspring that beat the - running best. Below the Rechenberg 1/5 threshold → step size is - too small / search is stuck → grow scope. Above → damp scope. + """Pick a boldness level and matching scope band for the next mutation. - ``parent_score`` no longer multiplies the whole signal (that was a - state-based damper that locked high-score lineages into "tiny - tweak" mode even when the gradient repeated identically). It only - re-enters as a *near-finish* soft floor in the last 5 % of score - range, where a single regression could blow up a workflow about to - hit the early-stop threshold. + Blends two fitness-grounded signals: ``success_rate`` (Rechenberg 1/5) + and ``plateau`` (``iters_since_improvement`` over ``_PLATEAU_PATIENCE``). + ``parent_score`` enters only as a near-finish damper in the last 5 % + of range. The top RE-SPECIATION band is hysteresis-gated: both + ``iters_since_improvement >= _RESPECIATION_PATIENCE`` and + ``success_rate in {None, 0.0}`` must hold. - Updates ``self.agent_count_history`` as a side effect. + Updates ``self.agent_count_history`` and ``self.last_variation_state``. Args: parent_score: Parent reward in ``[0, 1]``. Returns: - A one-line human-readable mutation-scope directive embeddable in - the LLM prompt. + One-line mutation-scope directive embeddable in the LLM prompt. """ - raw_stagnation = self._compute_stagnation() + iters_since_improvement = self._iters_since_improvement() + plateau = min(1.0, iters_since_improvement / self._PLATEAU_PATIENCE) success_rate = self._compute_success_rate() parent_score = float(np.clip(parent_score, 0.0, 1.0)) - # ── Rechenberg 1/5 rule, projected onto a [0, 1] boldness scalar ── thr = self._SUCCESS_RULE_THRESHOLD if success_rate is None: - # Cold start — no improvement evidence yet. Trust gradient - # repetition alone; also the back-compat path when the caller - # does not pass scores. - effective = raw_stagnation - elif success_rate < thr: - # Search is stuck (or has never improved). Below 1/5, escalate - # at least up to the deficit even when gradient repetition is mild. - deficit = (thr - success_rate) / thr # ∈ [0, 1] - effective = max(raw_stagnation, deficit) - else: - # Above 1/5 — real progress. Damp boldness in proportion to - # how far above threshold we are; at success_rate ≥ 0.80 - # boldness collapses regardless of stagnation. + effective = 0.3 * plateau # cold start cap + elif success_rate >= thr: progress = min(1.0, (success_rate - thr) / (0.80 - thr)) - effective = raw_stagnation * (1.0 - progress) + effective = plateau * (1.0 - progress) + else: + deficit = (thr - success_rate) / thr + effective = 0.5 * deficit + 0.5 * plateau - # Near-finish floor: only in the last 5 % of the score range do we - # re-introduce a mild parent_score damper, so the optimiser does - # not gamble away a 0.96 parent one generation before early-stop. near_finish = max(0.0, (parent_score - 0.95) / 0.05) effective *= (1.0 - 0.5 * near_finish) effective = float(np.clip(effective, 0.0, 1.0)) + respeciation_allowed = ( + iters_since_improvement >= self._RESPECIATION_PATIENCE + and (success_rate is None or success_rate == 0.0) + ) + if not respeciation_allowed: + effective = min(effective, self._RESPECIATION_CLAMP) + curr = self.agent_count_history[-1] if self.agent_count_history else 1 budget = curr + round(effective * (self.max_possible_agents - curr)) n_agents = self._sample_agent_count(effective, 1, budget) @@ -227,7 +176,7 @@ def _get_prompt_step_size(self, parent_score: float = 0.0) -> str: sr_repr = "n/a" if success_rate is None else f"{success_rate:.2f}" msg = ( f"Boldness effective={effective:.2f} " - f"(raw_stagnation={raw_stagnation:.2f}, " + f"(plateau={plateau:.2f}, iters_no_improve={iters_since_improvement}, " f"success_rate={sr_repr}, parent_score={parent_score:.2f})." ) if effective > 0.5: @@ -279,10 +228,12 @@ def _get_prompt_step_size(self, parent_score: float = 0.0) -> str: ] scope = next(label for threshold, label in bands if effective < threshold) self.last_variation_state = { - "stagnation": float(raw_stagnation), + "iters_since_improvement": int(iters_since_improvement), + "plateau": float(plateau), "success_rate": None if success_rate is None else float(success_rate), "effective_boldness": float(effective), "parent_score": float(parent_score), + "respeciation_gate_open": bool(respeciation_allowed), "scope_band": scope, "agent_budget": int(n_agents), } @@ -513,37 +464,65 @@ def crossover_prompt( if __name__ == "__main__": np.random.seed(0) - # ── _compute_stagnation: failures are excluded ──────────────────────── + # ── _iters_since_improvement: empty history ────────────────────────── + ve = VariationEngine() + assert ve._iters_since_improvement() == 0 + assert ve._compute_success_rate() is None + + # ── _iters_since_improvement: failures and unscored entries skipped ── ve = VariationEngine() for _ in range(4): ve.record_offspring_gradient("anything", is_failure=True) - assert ve._compute_stagnation() == 0.0 - assert ve._compute_success_rate() is None # no scored offspring + ve.record_offspring_gradient("no scores attached") # child_score=None + assert ve._iters_since_improvement() == 0 + assert ve._compute_success_rate() is None - # ── _compute_stagnation: repeated gradient ⇒ high cosine ────────────── + # ── _iters_since_improvement: counts only consecutive non-improvers ── + ve = VariationEngine() + ve.record_offspring_gradient("improve", child_score=0.30, best_before=0.20) + ve.record_offspring_gradient("flat", child_score=0.30, best_before=0.30) + ve.record_offspring_gradient("flat", child_score=0.30, best_before=0.30) + ve.record_offspring_gradient("crash", is_failure=True) # transparent + ve.record_offspring_gradient("flat", child_score=0.30, best_before=0.30) + assert ve._iters_since_improvement() == 3, ve._iters_since_improvement() + + # ── _iters_since_improvement: latest improvement resets streak to 0 ── ve = VariationEngine() for _ in range(4): - ve.record_offspring_gradient("INCONSISTENT_MULTITASK_SPLIT repeating") - assert ve._compute_stagnation() > 0.8 + ve.record_offspring_gradient("flat", child_score=0.5, best_before=0.5) + ve.record_offspring_gradient("up", child_score=0.6, best_before=0.5) + assert ve._iters_since_improvement() == 0 - # ── Plateau case: same gradient, zero improvement, high parent_score. - # Old behaviour (state-based damping) wrongly returned "tiny tweak". - # New behaviour (1/5 rule) must escalate scope. + # ── Plateau case: 5 non-improving offspring at 0.92. + # Hysteresis gate keeps us out of RE-SPECIATION (iters=5 < 8) but + # boldness must clear the smallest band. ve = VariationEngine() for _ in range(5): ve.record_offspring_gradient( "DATA_LEAKAGE: same diagnosis again", child_score=0.92, - best_before=0.92, # no improvement + best_before=0.92, ) - assert ve._compute_success_rate() == 0.0, "5/5 non-improving offspring" + assert ve._compute_success_rate() == 0.0 plateau_step = ve._get_prompt_step_size(parent_score=0.92) - assert "tweak" not in plateau_step, ( - "1/5 rule must lift scope past the smallest band when stuck:\n" - + plateau_step - ) + state = ve.last_variation_state + assert state["effective_boldness"] >= 0.35, state + assert state["effective_boldness"] < 0.90, state + assert state["respeciation_gate_open"] is False, state + assert "RE-SPECIATION" not in plateau_step, plateau_step - # ── Real progress: every offspring beats best_before ⇒ damp boldness ── + # ── Hysteresis gate opens at iters_since_improvement ≥ 8 + success=0. ── + ve = VariationEngine() + for _ in range(8): + ve.record_offspring_gradient( + "stuck", child_score=0.5, best_before=0.5, + ) + deep_stuck_step = ve._get_prompt_step_size(parent_score=0.5) + state = ve.last_variation_state + assert state["respeciation_gate_open"] is True, state + assert "RE-SPECIATION" in deep_stuck_step, deep_stuck_step + + # ── Real progress: improvements drop boldness to the smallest band. ── ve = VariationEngine() prev_best = 0.5 for inc in (0.05, 0.07, 0.09, 0.11, 0.13): @@ -555,32 +534,30 @@ def crossover_prompt( prev_best += inc assert ve._compute_success_rate() == 1.0 progress_step = ve._get_prompt_step_size(parent_score=0.5) - assert "tweak" in progress_step or "information flow" in progress_step, ( - "When the search is improving steadily, scope should stay small:\n" - + progress_step - ) + assert ve.last_variation_state["effective_boldness"] < 0.35, ve.last_variation_state + assert "EXPLOITATION" in progress_step, progress_step - # ── Near-finish floor: at parent_score≥0.95 we damp by 50 % ────────── + # ── Near-finish floor: at parent=1.0 the damper halves the pre-clamp + # boldness. Use a 3-iter streak so the result stays well below the + # hysteresis clamp at both parent scores — otherwise the clamp + # masks the comparison. ve = VariationEngine() - for _ in range(5): + for _ in range(3): ve.record_offspring_gradient( - "stuck near finish", - child_score=0.96, - best_before=0.96, + "stuck near finish", child_score=0.5, best_before=0.5, ) - near_finish_step = ve._get_prompt_step_size(parent_score=0.96) - # Boldness should still be > 0 (we are stuck), but capped. - print("near-finish step:", near_finish_step) - - # ── Cold start (no scores supplied): fall back to gradient-only ─────── + ve._get_prompt_step_size(parent_score=0.50) + bold_low_val = ve.last_variation_state["effective_boldness"] + ve._get_prompt_step_size(parent_score=1.00) + bold_high_val = ve.last_variation_state["effective_boldness"] + assert bold_low_val < 0.89 and bold_high_val < 0.89, (bold_low_val, bold_high_val) + assert abs(bold_high_val - 0.5 * bold_low_val) < 1e-6, (bold_high_val, bold_low_val) + + # ── Cold start (no scores supplied): boldness stays ≤ 0.30. ─────────── ve = VariationEngine() - for g in ( - "DEEPCHEM_API_MISMATCH:The workflow produced a usable two-task probability prediction table, but the final training script is not reliably runnable because it calls an unavailable DeepChem model API, and it also lacks a clear held-out classification metric report.", - "INCONSISTENT_MULTITASK_SPLIT:The workflow generated plausible probability predictions, but its script and outputs were internally inconsistent, with duplicate molecule rows and weak evidence that the provided train/test split, ECFP features, and both ClinTox endpoints were actually used in a true two-output multitask classifier.", - "INCONSISTENT_MULTITASK_SPLIT:The workflow produced a plausible multitask ClinTox prediction table, but it showed serious integrity issues around endpoint/positive-class mapping, possible train-test contamination, and unclear alignment between molecules and their predicted probabilities.", - ): + for g in ("alpha", "beta", "gamma"): ve.record_offspring_gradient(g) - stag = ve._compute_stagnation() step = ve._get_prompt_step_size(parent_score=0.5) - print(f"Gradient prompt: {g}\nStagnation: {stag:.2f}\nPrompt step:\n{step}\n{'-'*40}") + assert ve.last_variation_state["effective_boldness"] <= 0.3 + 1e-9, ve.last_variation_state + print(f"Cold-start gradient '{g}': {step}") print("smoke OK") \ No newline at end of file diff --git a/tests/variation_engine_test.py b/tests/variation_engine_test.py index 96db64f..1871d23 100644 --- a/tests/variation_engine_test.py +++ b/tests/variation_engine_test.py @@ -1,4 +1,4 @@ -"""Tests for VariationEngine stagnation signal and adaptive step size.""" +"""Tests for VariationEngine plateau counter, success-rate signal and adaptive step size.""" import sys from pathlib import Path @@ -11,62 +11,89 @@ from sources.core.variation_engine import VariationEngine -def test_textual_gradient_similarity_bounds_and_identity(): - ve = VariationEngine() - sim_same = ve._textual_gradient_similarity("foo bar baz", "foo bar baz") - assert 0.99 <= sim_same <= 1.0 + 1e-6 - assert ve._textual_gradient_similarity("", "anything") == 0.0 - assert ve._textual_gradient_similarity("foo", "") == 0.0 - assert ve._textual_gradient_similarity("", "") == 0.0 +# ── _iters_since_improvement ────────────────────────────────────────────── -def _stub_similarity(ve: VariationEngine, fn) -> None: - ve._textual_gradient_similarity = fn.__get__(ve, type(ve)) # type: ignore[attr-defined] +def test_iters_since_improvement_empty_history_is_zero(): + ve = VariationEngine() + assert ve._iters_since_improvement() == 0 -def test_stagnation_empty_history_is_zero(): +def test_iters_since_improvement_no_scored_entries_is_zero(): ve = VariationEngine() - assert ve._compute_stagnation() == 0.0 + for _ in range(4): + ve.record_offspring_gradient("crash", is_failure=True) + ve.record_offspring_gradient("no scores attached") # child_score = None + assert ve._iters_since_improvement() == 0 -def test_stagnation_single_entry_is_zero(): +def test_iters_since_improvement_counts_consecutive_non_improvers(): ve = VariationEngine() - ve.record_offspring_gradient("only one entry") - assert ve._compute_stagnation() == 0.0 + ve.record_offspring_gradient("up", child_score=0.30, best_before=0.20) + ve.record_offspring_gradient("flat", child_score=0.30, best_before=0.30) + ve.record_offspring_gradient("flat", child_score=0.30, best_before=0.30) + ve.record_offspring_gradient("flat", child_score=0.30, best_before=0.30) + assert ve._iters_since_improvement() == 3 -def test_stagnation_high_when_offspring_gradients_repeat(): +def test_iters_since_improvement_resets_after_strict_improvement(): ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0) for _ in range(4): - ve.record_offspring_gradient("same failure mode every time") - assert ve._compute_stagnation() == 1.0 + ve.record_offspring_gradient("flat", child_score=0.5, best_before=0.5) + ve.record_offspring_gradient("up", child_score=0.6, best_before=0.5) + assert ve._iters_since_improvement() == 0 -def test_stagnation_low_when_offspring_gradients_diverge(): +def test_iters_since_improvement_skips_failures_and_none_entries(): + """Failures and unscored entries are transparent: they neither break nor + extend the streak. The streak walks through them as if they weren't there.""" ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 0.4) - for g in ("a", "b", "c", "d"): - ve.record_offspring_gradient(g) - assert ve._compute_stagnation() < 1e-9 + ve.record_offspring_gradient("flat", child_score=0.5, best_before=0.5) + ve.record_offspring_gradient("crash", is_failure=True) + ve.record_offspring_gradient("nul", child_score=None, best_before=0.5) + ve.record_offspring_gradient("flat", child_score=0.5, best_before=0.5) + assert ve._iters_since_improvement() == 2 -def test_stagnation_window_only_considers_recent(): +def test_iters_since_improvement_tie_is_not_an_improvement(): + """``c == b`` is a tie, not a strict improvement: the streak must NOT + reset on equality.""" ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0 if a == b else 0.4) - ve.record_offspring_gradient("ancient_a") - ve.record_offspring_gradient("ancient_b") - for _ in range(4): - ve.record_offspring_gradient("recent_stuck") - assert ve._compute_stagnation() == 1.0 + ve.record_offspring_gradient("flat", child_score=0.5, best_before=0.5) + ve.record_offspring_gradient("flat", child_score=0.5, best_before=0.5) + assert ve._iters_since_improvement() == 2 -def test_failure_tagged_gradients_are_filtered_from_stagnation(): +# ── _compute_success_rate (kept; unchanged contract) ────────────────────── + + +def test_compute_success_rate_none_when_no_scored_history(): ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0) - for _ in range(4): - ve.record_offspring_gradient("anything", is_failure=True) - assert ve._compute_stagnation() == 0.0 + ve.record_offspring_gradient("only a gradient, no score") + assert ve._compute_success_rate() is None + + +def test_compute_success_rate_counts_strict_improvements(): + ve = VariationEngine() + # 2 improvements out of 4 ⇒ success_rate = 0.5 + ve.record_offspring_gradient("a", child_score=0.30, best_before=0.20) + ve.record_offspring_gradient("b", child_score=0.30, best_before=0.30) # tie ≠ improvement + ve.record_offspring_gradient("c", child_score=0.40, best_before=0.30) + ve.record_offspring_gradient("d", child_score=0.40, best_before=0.40) # tie ≠ improvement + assert ve._compute_success_rate() == 0.5 + + +def test_compute_success_rate_excludes_failures(): + ve = VariationEngine() + ve.record_offspring_gradient("ok", child_score=0.50, best_before=0.40) + ve.record_offspring_gradient( + "crash", is_failure=True, child_score=0.99, best_before=0.40 + ) + ve.record_offspring_gradient("ok", child_score=0.55, best_before=0.50) + assert ve._compute_success_rate() == 1.0 + + +# ── record_offspring_gradient: history bookkeeping unchanged ────────────── def test_failure_entries_persist_in_history_for_audit(): @@ -76,31 +103,30 @@ def test_failure_entries_persist_in_history_for_audit(): assert ve.textual_gradient_history == [("crash", True), ("real diag", False)] +# ── _get_prompt_step_size routing ───────────────────────────────────────── + + def test_step_size_damped_when_recent_offspring_improve(): - """Rechenberg 1/5 rule: with success_rate above threshold, even high - gradient stagnation should yield a small mutation scope.""" + """When success_rate ≥ 0.80, boldness collapses regardless of plateau.""" ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0) prev_best = 0.5 for inc in (0.05, 0.07, 0.09, 0.11, 0.13): ve.record_offspring_gradient( - "repeating diagnosis text", + "PROGRESS " + str(inc), child_score=prev_best + inc, best_before=prev_best, ) prev_best += inc block = ve._get_prompt_step_size(parent_score=0.97) - assert "prompt-only little tweak" in block, block + assert ve.last_variation_state["effective_boldness"] < 0.35, ve.last_variation_state + assert "EXPLOITATION" in block, block -def test_step_size_unleashed_when_recent_offspring_stuck_despite_high_parent(): - """Plateau case: high parent_score + zero improvements + repeating - gradient must NOT lock the search into 'tiny tweak' mode. This is the - regression test for the 0.92 plateau bug — before the 1/5-rule rewrite, - the old (1 − parent_score) damping returned "prompt-only little tweak" - here. The principled fix escalates scope.""" +def test_step_size_plateau_escalates_but_hysteresis_blocks_respeciation(): + """Plateau at high parent_score: scope must lift past EXPLOITATION but + must not reach RE-SPECIATION on a 5-iteration streak — the hysteresis + gate requires iters_since_improvement ≥ 8.""" ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0) for _ in range(5): ve.record_offspring_gradient( "DATA_LEAKAGE: same diagnosis again", @@ -108,32 +134,60 @@ def test_step_size_unleashed_when_recent_offspring_stuck_despite_high_parent(): best_before=0.92, ) block = ve._get_prompt_step_size(parent_score=0.92) - assert "tweak" not in block, block - assert any(tag in block for tag in ("rewire", "rethink", "redesign")), block + state = ve.last_variation_state + assert state["effective_boldness"] >= 0.35, state + assert state["effective_boldness"] < 0.90, state + assert state["respeciation_gate_open"] is False, state + assert "RE-SPECIATION" not in block, block -def test_step_size_unleashed_when_parent_score_low_and_stuck(): +def test_step_size_low_parent_and_stuck_lifts_scope(): ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0) for _ in range(5): ve.record_offspring_gradient( "repeating failure", child_score=0.10, best_before=0.10, ) - block = ve._get_prompt_step_size(parent_score=0.10) - assert ("rewire" in block) or ("rethink" in block), block + ve._get_prompt_step_size(parent_score=0.10) + state = ve.last_variation_state + # 5 consecutive non-improvers + success_rate = 0.0 must escape EXPLOITATION. + assert state["effective_boldness"] >= 0.35, state + + +def test_hysteresis_gate_opens_after_eight_consecutive_non_improvers(): + """RE-SPECIATION fires only when iters_since_improvement ≥ 8 AND + success_rate ∈ {None, 0.0}.""" + ve = VariationEngine() + for _ in range(8): + ve.record_offspring_gradient( + "stuck", child_score=0.5, best_before=0.5, + ) + block = ve._get_prompt_step_size(parent_score=0.5) + state = ve.last_variation_state + assert state["respeciation_gate_open"] is True, state + assert "RE-SPECIATION" in block, block + + +def test_cold_start_boldness_capped_at_thirty_percent(): + """No scored offspring → success_rate is None → effective ≤ 0.3 · plateau.""" + ve = VariationEngine() + for _ in range(20): + ve.record_offspring_gradient("just a diagnosis, no score") + ve._get_prompt_step_size(parent_score=0.5) + state = ve.last_variation_state + assert state["success_rate"] is None, state + assert state["effective_boldness"] <= 0.3 + 1e-9, state def test_step_size_parent_score_clipped_to_unit_interval(): - """Out-of-range parent_score must be clipped to [0, 1] before use; the - boldness value reported in the prompt stays a percentage in [0, 100].""" + """Out-of-range parent_score must be clipped to [0, 1]; the reported + boldness percentage stays inside [0, 100].""" ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 1.0) for _ in range(4): - ve.record_offspring_gradient("repeating failure") - # Clipping behaviour: parent_score=1.5 must be treated as 1.0, not - # crash and not produce a negative or > 100 % boldness in the output. + ve.record_offspring_gradient( + "stuck", child_score=0.5, best_before=0.5, + ) block = ve._get_prompt_step_size(parent_score=1.5) import re m = re.search(r"Boldness:\s*([\d.]+)%", block) @@ -142,35 +196,8 @@ def test_step_size_parent_score_clipped_to_unit_interval(): assert 0.0 <= boldness_pct <= 100.0, boldness_pct -def test_compute_success_rate_none_when_no_scored_history(): - ve = VariationEngine() - ve.record_offspring_gradient("only a gradient, no score") - assert ve._compute_success_rate() is None - - -def test_compute_success_rate_counts_strict_improvements(): - ve = VariationEngine() - # 2 improvements out of 4 ⇒ success_rate = 0.5 - ve.record_offspring_gradient("a", child_score=0.30, best_before=0.20) - ve.record_offspring_gradient("b", child_score=0.30, best_before=0.30) # tie ≠ improvement - ve.record_offspring_gradient("c", child_score=0.40, best_before=0.30) - ve.record_offspring_gradient("d", child_score=0.40, best_before=0.40) # tie ≠ improvement - assert ve._compute_success_rate() == 0.5 - - -def test_compute_success_rate_excludes_failures(): - ve = VariationEngine() - ve.record_offspring_gradient("ok", child_score=0.50, best_before=0.40) - ve.record_offspring_gradient( - "crash", is_failure=True, child_score=0.99, best_before=0.40 - ) - ve.record_offspring_gradient("ok", child_score=0.55, best_before=0.50) - assert ve._compute_success_rate() == 1.0 - - def test_mutation_prompt_does_not_touch_gradient_history(): ve = VariationEngine() - _stub_similarity(ve, lambda self, a, b: 0.4) class _FakeWfInfo: overall_score = 0.5 From bdf45a24704d73a2567ba7fbaed52edd22fdb7ff Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 17:00:29 +0200 Subject: [PATCH 21/71] feat(verifier): bounded retry on verifier-side failures + per-claim file split --- sources/core/evaluators/verifier.py | 223 ++-- sources/core/evaluators/verifier_per_claim.py | 1131 ++++++++++------- 2 files changed, 793 insertions(+), 561 deletions(-) diff --git a/sources/core/evaluators/verifier.py b/sources/core/evaluators/verifier.py index ff87732..d1e647f 100644 --- a/sources/core/evaluators/verifier.py +++ b/sources/core/evaluators/verifier.py @@ -308,25 +308,10 @@ def evaluate( return {"uuid": uuid, "claims": [], **scores} self._ensure_verifier_packages() - - anchored_specs = { - c["id"]: (bool(c.get("executable")), str(c.get("reason") or "")) - for c in (anchored_records or []) - } claims_to_verify = claims[: self.max_claims] - - # Pre-resolve specs: anchor-cache hits are O(disk read) each, but every - # other claim costs two judge round-trips (file selection + spec gen). - anchor_preloaded: dict[str, dict[str, Any]] = {} - needs_generation: list[dict[str, Any]] = [] - for claim in claims_to_verify: - if anchored_records and rubric_anchor_uuid and claim["id"] in anchored_specs: - executable, reason = anchored_specs[claim["id"]] - anchor_preloaded[claim["id"]] = self._spec_from_anchor( - rubric_anchor_uuid, claim["id"], executable, reason - ) - else: - needs_generation.append(claim) + anchor_preloaded, needs_generation = self._partition_specs_by_anchor( + claims_to_verify, anchored_records, rubric_anchor_uuid + ) st = time.time() generated = self._generate_specs_parallel( @@ -730,6 +715,33 @@ def _upgrade_legacy_anchor(cls, rec: dict[str, Any]) -> dict[str, Any]: } return rec + def _partition_specs_by_anchor( + self, + claims_to_verify: list[dict[str, Any]], + anchored_records: list[dict[str, Any]] | None, + rubric_anchor_uuid: str | None, + ) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]: + """Split claims into anchor-preloaded specs vs. those needing generation. + + Anchor-cache hits are O(disk read); every other claim costs two judge + round-trips (file selection + spec gen). + """ + anchored_specs = { + c["id"]: (bool(c.get("executable")), str(c.get("reason") or "")) + for c in (anchored_records or []) + } + anchor_preloaded: dict[str, dict[str, Any]] = {} + needs_generation: list[dict[str, Any]] = [] + for claim in claims_to_verify: + if anchored_specs and rubric_anchor_uuid and claim["id"] in anchored_specs: + executable, reason = anchored_specs[claim["id"]] + anchor_preloaded[claim["id"]] = self._spec_from_anchor( + rubric_anchor_uuid, claim["id"], executable, reason + ) + else: + needs_generation.append(claim) + return anchor_preloaded, needs_generation + def _spec_from_anchor( self, anchor_uuid: str, @@ -1035,6 +1047,10 @@ def _persist_textual_gradient(self, uuid: str, textual_gradient: str) -> None: # Report rendering + persistence # ------------------------------------------------------------------ + _REPORT_SEPARATOR_BAR = "=" * 60 + _REPORT_SECTION_BAR = "-" * 60 + _REPORT_STDERR_TAIL_LINES = 5 + def _build_report( self, per_claim: list[dict[str, Any]], @@ -1044,89 +1060,104 @@ def _build_report( ) -> str: """Render a plain-text report from per-claim results and aggregate scores. - Args: - per_claim: List of per-claim scored dicts. - scores: Aggregated score dict from ``_aggregate``. - cheat: Cheat-detector report, or ``None``. - min_importance: When > 0, only claims with importance ≥ this value - are rendered. The aggregate header still reflects the full run. - Used to build a noise-suppressed view for the prompt-gradient - builder, while the on-disk ``evaluation.txt`` keeps the full - report (``min_importance=0``). - - Returns: - Multi-line report string terminated with a newline. + When ``min_importance > 0`` only claims at or above that bar are + rendered; the aggregate header still reflects the full run. Used by + the prompt-gradient builder for a noise-suppressed view, while the + on-disk ``evaluation.txt`` keeps the full report (``min_importance=0``). """ - lines: list[str] = [] - w = lines.append - - w("Verifier Evaluation") - w("=" * 60) - w( - f"Claims: {scores['n_claims']} pass={scores['n_pass']} " - f"fail={scores['n_fail']} error={scores.get('n_error', 0)} " - f"unsure={scores.get('n_unsure', 0)} " - f"scored={scores.get('n_scored', 0)}" - ) - w( - f"Overall: {scores['overall_score']:.3f}" - f" (pre-cheat {scores.get('overall_score_before_cheat', scores['overall_score']):.3f}, " - f"uncapped {scores.get('overall_score_uncapped', 0.0):.3f}, " - f"hard_fail_capped={scores.get('hard_fail_capped', False)})" - ) - w( - f" base_mean={scores.get('base_mean', 0.0):.3f} " - f"information_bonus={scores.get('information_bonus', 0.0):.3f} " - f"n_high_importance_pass={scores.get('n_high_importance_pass', 0)} " - f"high_importance_pass_mass={scores.get('high_importance_pass_mass', 0.0):.3f} " - f"cheat_penalty={scores.get('cheat_penalty', 0.0):.3f}" - ) + lines: list[str] = list(self._format_report_header(scores, min_importance)) + for c in per_claim: + lines.extend(self._format_claim_entry(c, min_importance)) + lines.extend(self._format_cheat_section(cheat)) + return "\n".join(lines) + "\n" + + def _format_report_header( + self, scores: dict[str, Any], min_importance: int + ) -> list[str]: + """Render the report header (title, counts, score breakdown, filter note).""" + lines = [ + "Verifier Evaluation", + self._REPORT_SEPARATOR_BAR, + ( + f"Claims: {scores['n_claims']} pass={scores['n_pass']} " + f"fail={scores['n_fail']} error={scores.get('n_error', 0)} " + f"unsure={scores.get('n_unsure', 0)} " + f"scored={scores.get('n_scored', 0)}" + ), + ( + f"Overall: {scores['overall_score']:.3f}" + f" (pre-cheat {scores.get('overall_score_before_cheat', scores['overall_score']):.3f}, " + f"uncapped {scores.get('overall_score_uncapped', 0.0):.3f}, " + f"hard_fail_capped={scores.get('hard_fail_capped', False)})" + ), + ( + f" base_mean={scores.get('base_mean', 0.0):.3f} " + f"information_bonus={scores.get('information_bonus', 0.0):.3f} " + f"n_high_importance_pass={scores.get('n_high_importance_pass', 0)} " + f"high_importance_pass_mass={scores.get('high_importance_pass_mass', 0.0):.3f} " + f"cheat_penalty={scores.get('cheat_penalty', 0.0):.3f}" + ), + ] if min_importance > 0: - w(f"(filtered view: importance ≥ {min_importance})") + lines.append(f"(filtered view: importance ≥ {min_importance})") + return lines + + def _format_claim_entry( + self, c: dict[str, Any], min_importance: int + ) -> list[str]: + """Render one claim's block; empty list when filtered out.""" + cl = c["claim"] + imp = int(cl.get("importance", self._DEFAULT_CLAIM_IMPORTANCE)) + if imp < min_importance: + return [] + lines = [self._format_claim_header(cl, imp)] + rel = cl.get("likely_relevant_files", []) + if rel: + lines.append(f" relevant_files: {rel}") + lines.append(f" kind={c['verifier_kind']} status={c['status']} score={c['score']}") + if c.get("details"): + lines.append(f" details: {c['details']}") + lines.extend(self._format_stderr_tail(c)) + lines.append("") + return lines - for c in per_claim: - cl = c["claim"] - imp = int(cl.get("importance", self._DEFAULT_CLAIM_IMPORTANCE)) - if imp < min_importance: - continue - rationale = str(cl.get("importance_rationale") or "").strip() - header = ( - f"[{cl['id']}] (importance={imp}; {rationale}) {cl['description']}" - if rationale - else f"[{cl['id']}] (importance={imp}) {cl['description']}" - ) - w(header) - rel = cl.get("likely_relevant_files", []) - if rel: - w(f" relevant_files: {rel}") - w(f" kind={c['verifier_kind']} status={c['status']} score={c['score']}") - if c.get("details"): - w(f" details: {c['details']}") - if c.get("verifier_kind") == "executable" and c.get("raw_stderr"): - stderr_snippet = c["raw_stderr"].strip().splitlines()[-5:] - if stderr_snippet: - w(" stderr (tail):") - for line in stderr_snippet: - w(f" {line}") - w("") + @staticmethod + def _format_claim_header(cl: dict[str, Any], imp: int) -> str: + """Format one claim's heading line (optionally suffixed with rationale).""" + rationale = str(cl.get("importance_rationale") or "").strip() + if rationale: + return f"[{cl['id']}] (importance={imp}; {rationale}) {cl['description']}" + return f"[{cl['id']}] (importance={imp}) {cl['description']}" - if cheat is not None: - w("-" * 60) - w("Independent cheat audit") - w(f" penalty: {cheat.penalty:.3f}") - if cheat.error: - w(f" error: {cheat.error}") - if cheat.behavioral: - w(" behavioral findings (also fed to mutator):") - for b in cheat.behavioral: - w(f" - {b}") - if cheat.mechanism: - w(" mechanism findings (audit-only — NOT fed to mutator):") - for m in cheat.mechanism: - w(f" - {m}") - w("") + @classmethod + def _format_stderr_tail(cls, c: dict[str, Any]) -> list[str]: + """Render the tail of stderr for executable verifiers; empty otherwise.""" + if c.get("verifier_kind") != "executable" or not c.get("raw_stderr"): + return [] + tail = c["raw_stderr"].strip().splitlines()[-cls._REPORT_STDERR_TAIL_LINES:] + if not tail: + return [] + return [" stderr (tail):", *(f" {line}" for line in tail)] - return "\n".join(lines) + "\n" + def _format_cheat_section(self, cheat: Any) -> list[str]: + """Render the cheat-audit block; empty when no cheat report available.""" + if cheat is None: + return [] + lines = [ + self._REPORT_SECTION_BAR, + "Independent cheat audit", + f" penalty: {cheat.penalty:.3f}", + ] + if cheat.error: + lines.append(f" error: {cheat.error}") + if cheat.behavioral: + lines.append(" behavioral findings (also fed to mutator):") + lines.extend(f" - {b}" for b in cheat.behavioral) + if cheat.mechanism: + lines.append(" mechanism findings (audit-only — NOT fed to mutator):") + lines.extend(f" - {m}" for m in cheat.mechanism) + lines.append("") + return lines def _write_report( self, diff --git a/sources/core/evaluators/verifier_per_claim.py b/sources/core/evaluators/verifier_per_claim.py index e1ea05e..7968043 100644 --- a/sources/core/evaluators/verifier_per_claim.py +++ b/sources/core/evaluators/verifier_per_claim.py @@ -1,6 +1,4 @@ -""" -Per-claim verifier generation, sandboxed execution and scoring. -""" +"""Per-claim verifier generation, sandboxed execution and scoring.""" from __future__ import annotations @@ -12,21 +10,17 @@ import threading import time from collections.abc import Callable, Coroutine -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import Future, ThreadPoolExecutor, as_completed from typing import Any, TypeVar if __name__ == "__main__": - sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) - -from sources.cli.pretty_print import ( - CYAN, - DIM, - GREEN, - RED, - YELLOW, - print_box, -) + sys.path.append( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + ) + +from sources.cli.pretty_print import CYAN, DIM, GREEN, RED, YELLOW, print_box from sources.core.workflow_runner import ( + ExecutionResult, ExecutionStatus, RuntimeConfig, WorkflowRunner, @@ -35,29 +29,41 @@ # ----- Verifier helper packages ---------------------------------------------- # Installed once per process so verifier scripts can rely on them being -# importable. Kept deliberately minimal: numerical + tabular + classical stats -# + standard ML primitives. Anything heavier should be inferred from the -# workflow's own declared dependencies, not bolted onto the verifier. +# importable. Minimal on purpose: numerical + tabular + classical stats + ML. _VERIFIER_BASE_PACKAGES: tuple[str, ...] = ( - "numpy", - "pandas", - "scipy", - "scikit-learn", - "pint", - "pydantic", - "pandera", - "jsonschema", - "sympy", - "openpyxl" + "numpy", "pandas", "scipy", "scikit-learn", "pint", + "pydantic", "pandera", "jsonschema", "sympy", "openpyxl", ) - -# Python module names corresponding to ``_VERIFIER_BASE_PACKAGES`` -# (scikit-learn → sklearn). Used by the post-install smoke check. +# Python module names corresponding to ``_VERIFIER_BASE_PACKAGES`` for the +# post-install smoke check (scikit-learn → sklearn). _VERIFIER_BASE_IMPORTS: tuple[str, ...] = ("numpy", "pandas", "scipy", "sklearn") _VERIFIER_PACKAGES_INSTALLED = False _VERIFIER_INSTALL_LOCK = threading.Lock() +# ----- Tunables -------------------------------------------------------------- +# Caps applied during the bounded-retry policy so a pathological claim cannot +# snowball into an open-ended install + regen loop. +_RECOVERY_MAX_INSTALL_PACKAGES = 6 +_RECOVERY_INSTALL_TIMEOUT_SECONDS = 300 +_RECOVERY_STDERR_FEEDBACK_LINES = 30 +# Substrings that identify a missing-dependency stderr; anything else is +# treated as a generated-code bug and routed through script regeneration. +_RECOVERY_IMPORT_MARKERS: tuple[str, ...] = ( + "ModuleNotFoundError", + "ImportError: No module named", + "ImportError: cannot import name", +) +_BASE_INSTALL_TIMEOUT_SECONDS = 600 +_RUNNER_CLEANUP_TIMEOUT = 15 +_RUNNER_SMOKE_TIMEOUT = 15.0 +_RUNNER_EXTRA_TIMEOUT = 10 +_PIP_INSTALL_FLAGS: tuple[str, ...] = ( + "--quiet", "--disable-pip-version-check", "--break-system-packages", +) +_PRINT_TRUNCATE_BYTES = 256 +_STDERR_TAIL_BYTES = 400 + T = TypeVar("T") @@ -66,35 +72,25 @@ def _run_coro_sync( coro_factory: Callable[[], Coroutine[Any, Any, T]], thread_timeout: float | None = None, ) -> T: - """Run an async coroutine from sync code, even if a loop is already running. - - The coroutine is built lazily so it can never be orphaned on a failed run. - - Args: - coro_factory: Zero-arg callable that constructs the coroutine to await. - thread_timeout: When already inside a running loop, time budget in - seconds for the worker thread to finish before raising. - - Returns: - The value returned by the coroutine. - - Raises: - TimeoutError: When the worker thread exceeds ``thread_timeout``. - BaseException: Re-raises any exception raised by the coroutine. - """ + """Run an async coroutine from sync code, even if a loop already runs.""" try: asyncio.get_running_loop() in_loop = True except RuntimeError: in_loop = False - if not in_loop: return asyncio.run(coro_factory()) + return _run_coro_in_worker(coro_factory, thread_timeout) + +def _run_coro_in_worker( + coro_factory: Callable[[], Coroutine[Any, Any, T]], + thread_timeout: float | None, +) -> T: + """Spawn a daemon thread to run *coro_factory*; raise on timeout/error.""" holder: dict[str, Any] = {} def _target() -> None: - """Run the coroutine inside a worker thread and stash result/error.""" try: holder["result"] = asyncio.run(coro_factory()) except BaseException as exc: @@ -126,98 +122,129 @@ def _verify_claim( grounding: str = "", preloaded_spec: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Generate, execute (if executable) and score a single claim. - - Args: - uuid: Workflow identifier (used for judge calls and logs). - claim: Normalised claim dict with id, importance, description. - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. - grounding: Optional peer-reviewed literature grounding block. - preloaded_spec: Pre-built verifier spec from a lineage anchor. - When provided, both the file-selection LLM call and the - verifier-generation LLM call are skipped. - - Returns: - Scored claim dict including verifier spec, status, score, and the - ``elapsed_s`` wall-clock spent in this call (used by the - orchestrator to render a per-claim timing table). - """ + """Generate, execute and score a single claim; return the result dict.""" t_start = time.time() - if preloaded_spec is None: - rel_files = self._llm_select_files( - uuid, claim, execution_text, workspace_listing - ) - claim = {**claim, "likely_relevant_files": rel_files} - else: - rel_files = list(claim.get("likely_relevant_files") or []) - claim_text = ( - f"id: {claim.get('id')}\n" - f"importance: {claim.get('importance')}\n" - f"description: {claim.get('description')}\n" - f"files: {rel_files if rel_files else '(none)'}" + claim = self._resolve_relevant_files( + uuid, claim, execution_text, workspace_listing, preloaded_spec ) - print_box(claim_text, title=f"Verifying claim {claim.get('id')}", color=CYAN) - - spec = ( - preloaded_spec - if preloaded_spec is not None - else self._generate_verifier(uuid, claim, execution_text, workspace_listing) + self._print_claim_header(claim) + spec = preloaded_spec or self._generate_verifier( + uuid, claim, execution_text, workspace_listing + ) + spec, scored = self._run_and_score( + uuid, claim, spec, execution_text, workspace_listing, grounding ) - - if spec.get("executable") and spec.get("code"): - code = spec["code"] - exec_result = self._run_verifier(uuid, claim["id"], code) - - exit_status = exec_result.get("exit_status", "?") - run_status = exec_result.get("status", "?") - details = exec_result.get("details", "") or "" - stdout = exec_result.get("raw_stdout", "") or "" - stderr = exec_result.get("raw_stderr", "") or "" - - run_color = GREEN if run_status == "pass" else (YELLOW if run_status == "fail" else RED) - summary = ( - f"status: {run_status}\n" - f"exit_status: {exit_status}\n" - f"details: {details}" - ) - print_box(summary, title=f"Verifier run · {claim.get('id')}", color=run_color) - - if stdout.strip(): - print_box(stdout, title=f"stdout · {claim.get('id')}", color=DIM, truncate=256) - if stderr.strip(): - print_box(stderr, title=f"stderr · {claim.get('id')}", color=RED, truncate=256) - - scored = self._score_executable(claim, spec, exec_result) - else: - reason = spec.get("reason", "") - print_box( - f"Marked non-executable.\nreason: {reason or '(none provided)'}", - title=f"Verifier spec · {claim.get('id')}", - color=YELLOW, - ) - scored = self._score_soft(uuid, claim, execution_text, workspace_listing, reason, grounding) - print_box( - f"verdict: {scored.get('status')}\nrationale: {scored.get('rationale', '')}", - title=f"Soft check · {claim.get('id')}", - color=GREEN if scored.get("score", 0) >= 0.5 else RED, - ) - scored["claim"] = claim scored["spec"] = spec scored["elapsed_s"] = round(time.time() - t_start, 3) + self._print_claim_verdict(claim, scored) + return scored + + def _resolve_relevant_files( + self, + uuid: str, + claim: dict[str, Any], + execution_text: str, + workspace_listing: str, + preloaded_spec: dict[str, Any] | None, + ) -> dict[str, Any]: + """Populate ``likely_relevant_files`` via judge call when not anchored.""" + if preloaded_spec is not None: + return claim + rel_files = self._llm_select_files( + uuid, claim, execution_text, workspace_listing + ) + return {**claim, "likely_relevant_files": rel_files} + + @staticmethod + def _print_claim_header(claim: dict[str, Any]) -> None: + """Render the per-claim opening box.""" + files = claim.get("likely_relevant_files") or "(none)" + body = ( + f"id: {claim.get('id')}\n" + f"importance: {claim.get('importance')}\n" + f"description: {claim.get('description')}\n" + f"files: {files}" + ) + print_box(body, title=f"Verifying claim {claim.get('id')}", color=CYAN) - final = ( + @staticmethod + def _print_claim_verdict(claim: dict[str, Any], scored: dict[str, Any]) -> None: + """Render the final per-claim verdict box.""" + body = ( f"id: {claim.get('id')}\n" f"kind: {scored.get('verifier_kind')}\n" f"status: {scored.get('status')}\n" f"score: {scored.get('score')}\n" f"elapsed:{scored['elapsed_s']}s" ) + color = GREEN if scored.get("score", 0) >= 0.5 else RED + print_box(body, title=f"Claim verdict · {claim.get('id')}", color=color) + + def _run_and_score( + self, + uuid: str, + claim: dict[str, Any], + spec: dict[str, Any], + execution_text: str, + workspace_listing: str, + grounding: str, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Dispatch to executable or soft branch; return ``(final_spec, scored)``.""" + if spec.get("executable") and spec.get("code"): + spec, exec_result = self._run_verifier_with_recovery( + uuid, claim, spec, execution_text, workspace_listing + ) + self._print_executable_summary(claim, exec_result) + return spec, self._score_executable(claim, spec, exec_result) + return spec, self._run_soft_branch( + uuid, claim, spec, execution_text, workspace_listing, grounding + ) + + @staticmethod + def _print_executable_summary( + claim: dict[str, Any], exec_result: dict[str, Any] + ) -> None: + """Render run-status / stdout / stderr boxes for an executable run.""" + cid = claim.get("id") + run_status = exec_result.get("status", "?") + color = GREEN if run_status == "pass" else (YELLOW if run_status == "fail" else RED) + summary = ( + f"status: {run_status}\n" + f"exit_status: {exec_result.get('exit_status', '?')}\n" + f"details: {exec_result.get('details') or ''}" + ) + print_box(summary, title=f"Verifier run · {cid}", color=color) + stdout = (exec_result.get("raw_stdout") or "").strip() + stderr = (exec_result.get("raw_stderr") or "").strip() + if stdout: + print_box(stdout, title=f"stdout · {cid}", color=DIM, truncate=_PRINT_TRUNCATE_BYTES) + if stderr: + print_box(stderr, title=f"stderr · {cid}", color=RED, truncate=_PRINT_TRUNCATE_BYTES) + + def _run_soft_branch( + self, + uuid: str, + claim: dict[str, Any], + spec: dict[str, Any], + execution_text: str, + workspace_listing: str, + grounding: str, + ) -> dict[str, Any]: + """Run the non-executable scoring branch and render its boxes.""" + reason = spec.get("reason", "") + cid = claim.get("id") print_box( - final, - title=f"Claim verdict · {claim.get('id')}", - color=GREEN if scored.get("score", 0) >= 0.5 else RED, + f"Marked non-executable.\nreason: {reason or '(none provided)'}", + title=f"Verifier spec · {cid}", color=YELLOW, + ) + scored = self._score_soft( + uuid, claim, execution_text, workspace_listing, reason, grounding + ) + verdict_color = GREEN if scored.get("score", 0) >= 0.5 else RED + print_box( + f"verdict: {scored.get('status')}\nrationale: {scored.get('rationale', '')}", + title=f"Soft check · {cid}", color=verdict_color, ) return scored @@ -233,44 +260,23 @@ def _llm_select_files( workspace_listing: str, max_files: int = 3, ) -> list[str]: - """Pick workspace files most likely to hold this claim's artefact. - - Per-claim judge call that fuses the agent narration (names the files - the agents wrote), the workspace listing (ground truth of what's on - disk), and the claim description. Returns only paths present in the - workspace — hallucinated entries are dropped. On parse or provider - failure falls back to all eligible workspace files so the downstream - verifier-gen step is never blind. - - Args: - uuid: Workflow identifier (used for judge calls). - claim: Normalised claim dict. - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. - max_files: Maximum number of files to return. - - Returns: - List of workspace-relative paths the verifier should open. - """ + """Pick workspace files most likely to hold this claim's artefact.""" eligible = self._eligible_workspace_files() if not eligible: return [] prompt = self._build_select_files_prompt( claim, execution_text, workspace_listing, max_files ) - agent_name = f"verifier_select_files_{claim.get('id', 'unknown')}" - parsed, err = self._call_judge_for_json(uuid, agent_name, prompt) + cid = str(claim.get("id", "unknown")) + parsed, err = self._call_judge_for_json( + uuid, f"verifier_select_files_{cid}", prompt + ) if err or not isinstance(parsed, dict): - self.logger.debug( - f"file selection failed for {claim.get('id')}: " - f"{err or 'non-dict JSON'}" - ) + self.logger.debug(f"file selection failed for {cid}: {err or 'non-dict JSON'}") return eligible selected = self._validate_workspace_paths( - parsed.get("files"), - allowed=set(eligible), - max_count=max_files, - label=str(claim.get("id", "unknown")), + parsed.get("files"), allowed=set(eligible), + max_count=max_files, label=cid, ) return selected or eligible @@ -281,17 +287,7 @@ def _build_select_files_prompt( workspace_listing: str, max_files: int, ) -> str: - """Build the per-claim file-selection prompt sent to the judge. - - Args: - claim: Normalised claim dict. - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. - max_files: Maximum number of files to request. - - Returns: - Fully formatted prompt string for the judge. - """ + """Build the per-claim file-selection prompt sent to the judge.""" return f"""You are picking which workspace files a deterministic verifier should open to check ONE atomic claim about a multi-agent workflow. WORKSPACE FILES (namesize, relative to workspace root, cwd at runtime): @@ -324,23 +320,17 @@ def _generate_verifier( execution_text: str, workspace_listing: str, ) -> dict[str, Any]: - """Ask the judge for a verifier script (or a non-executable rationale). - - Args: - uuid: Workflow identifier (used for judge calls). - claim: Normalised claim dict. - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. + """Ask the judge for a verifier script (or a non-executable rationale).""" + prompt = self._build_verifier_prompt(claim, workspace_listing) + return self._call_and_parse_verifier(uuid, claim, prompt, attempt=1) - Returns: - Spec dict with either ``executable=True`` and ``code``, or - ``executable=False`` and a ``reason``. - """ - relevant_previews = self._render_relevant_previews( - claim.get("likely_relevant_files", []) - ) - packages = ', '.join(_VERIFIER_BASE_PACKAGES) - prompt = f""" + def _build_verifier_prompt( + self, claim: dict[str, Any], workspace_listing: str + ) -> str: + """Build the verifier-generation prompt for one claim.""" + previews = self._render_relevant_previews(claim.get("likely_relevant_files", [])) + packages = ", ".join(_VERIFIER_BASE_PACKAGES) + return f""" You are writing a tiny verifier program for ONE atomic claim from a multi-agent workflow. The verifier will run inside the same workspace the agents used. @@ -348,7 +338,7 @@ def _generate_verifier( {workspace_listing} RELEVANT FILE PREVIEWS (head + tail of files the claim depends on; truncated): -{relevant_previews} +{previews} CLAIM TO VERIFY: - id: {claim['id']} @@ -408,7 +398,6 @@ def _generate_verifier( {{"executable": true, "code": ""}} {{"executable": false, "reason": ""}} """ - return self._call_and_parse_verifier(uuid, claim, prompt, attempt=1) def _call_and_parse_verifier( self, @@ -417,22 +406,9 @@ def _call_and_parse_verifier( prompt: str, attempt: int, ) -> dict[str, Any]: - """Call the judge for a verifier spec; soft-fail to ``executable: False``. - - Args: - uuid: Workflow identifier (used for judge calls). - claim: Normalised claim dict. - prompt: Pre-built verifier-generation prompt. - attempt: 1 for the first try, >1 for retries (suffixes the agent name). - - Returns: - Spec dict; on errors returns ``{"executable": False, "reason": ...}``. - """ - agent_name = ( - f"verifier_gen_{claim['id']}" - if attempt == 1 - else f"verifier_gen_{claim['id']}_retry" - ) + """Call the judge for a verifier spec; soft-fail to ``executable: False``.""" + suffix = "" if attempt == 1 else "_retry" + agent_name = f"verifier_gen_{claim['id']}{suffix}" spec, err = self._call_judge_for_json(uuid, agent_name, prompt) if err is not None: return {"executable": False, "reason": err} @@ -451,29 +427,12 @@ def _select_files_and_generate_spec( execution_text: str, workspace_listing: str, ) -> tuple[dict[str, Any], dict[str, Any]]: - """One claim's pre-flight: pick relevant files, then generate the spec. - - These two judge calls are sequential within a single claim (the spec - prompt needs the file previews chosen here), so they're bundled - together as one worker unit for parallel fan-out across claims. - - Args: - uuid: Workflow identifier (used for judge calls). - claim: Normalised claim dict. - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. - - Returns: - ``(updated_claim, spec)`` — the claim with ``likely_relevant_files`` - populated, and the verifier spec from ``_generate_verifier``. - """ + """Pick relevant files then generate one claim's verifier spec.""" rel_files = self._llm_select_files( uuid, claim, execution_text, workspace_listing ) updated = {**claim, "likely_relevant_files": rel_files} - spec = self._generate_verifier( - uuid, updated, execution_text, workspace_listing - ) + spec = self._generate_verifier(uuid, updated, execution_text, workspace_listing) return updated, spec def _generate_specs_parallel( @@ -484,23 +443,7 @@ def _generate_specs_parallel( workspace_listing: str, max_workers: int, ) -> dict[str, tuple[dict[str, Any], dict[str, Any]]]: - """Fan out file selection + verifier generation across claims via threads. - - The LLM calls under the hood are sync HTTP; threading is enough to - overlap their network latency. Each claim writes to its own memory - file (agent name is keyed by ``claim['id']``), so no cache collisions. - - Args: - uuid: Workflow identifier (used for judge calls). - claims: Claims that need a freshly generated spec (anchored ones - should already be filtered out by the caller). - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. - max_workers: Upper bound on concurrent LLM calls. - - Returns: - Dict keyed by claim id mapping to ``(updated_claim, spec)``. - """ + """Fan out file selection + verifier generation across claims via threads.""" if not claims: return {} results: dict[str, tuple[dict[str, Any], dict[str, Any]]] = {} @@ -515,117 +458,85 @@ def _generate_specs_parallel( } for f in as_completed(futures): cid = futures[f] - try: - results[cid] = f.result() - except Exception as e: - # Mirror the soft-fail contract of _call_and_parse_verifier: - # never let one bad claim crash the whole batch. - self.logger.warning( - f"parallel spec generation failed for {cid}: " - f"{type(e).__name__}: {e}" - ) - results[cid] = ( - {**next(c for c in claims if c["id"] == cid), - "likely_relevant_files": []}, - {"executable": False, "reason": f"spec generation raised: {e}"}, - ) + results[cid] = self._collect_spec_future(f, cid, claims) return results + def _collect_spec_future( + self, + future: Future, + cid: str, + claims: list[dict[str, Any]], + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Resolve one parallel-gen future; on raise return a non-executable fallback.""" + try: + return future.result() + except Exception as e: + self.logger.warning( + f"parallel spec generation failed for {cid}: {type(e).__name__}: {e}" + ) + base = next(c for c in claims if c["id"] == cid) + return ( + {**base, "likely_relevant_files": []}, + {"executable": False, "reason": f"spec generation raised: {e}"}, + ) + # ------------------------------------------------------------------ # Verifier helper-package install (idempotent, lock-guarded) # ------------------------------------------------------------------ def _ensure_verifier_packages(self) -> None: - """Make verifier helper packages importable under ``sys.executable``. - - The verifier ``WorkflowRunner`` is forced to use ``sys.executable`` so - installs and runs target the same interpreter (the one running - Mimosa, typically a uv-managed venv). Idempotent across evaluator - instances via a module-level flag + lock. Logged-and-skipped on - failure — verifier scripts must then restrict themselves to stdlib. - """ + """Make verifier helper packages importable under ``sys.executable``.""" global _VERIFIER_PACKAGES_INSTALLED if _VERIFIER_PACKAGES_INSTALLED: return with _VERIFIER_INSTALL_LOCK: if _VERIFIER_PACKAGES_INSTALLED: return - - smoke_cmd = [ - sys.executable, "-c", - "import " + ", ".join(_VERIFIER_BASE_IMPORTS), - ] - - # Early-exit if the helper packages are already importable — - # common when Mimosa runs in a venv that already has them. - if self._smoke_check(smoke_cmd): - _VERIFIER_PACKAGES_INSTALLED = True - self.logger.info( - f"Verifier helper packages already importable under " - f"{sys.executable}" - ) - return - - # ``--break-system-packages`` is the documented escape from PEP 668 - # on system Pythons; inside a venv it is silently ignored. Modern - # uv-managed envs have a recent pip that supports the flag. - pip_cmd = [ - sys.executable, "-m", "pip", "install", "--quiet", - "--disable-pip-version-check", "--break-system-packages", - *_VERIFIER_BASE_PACKAGES, - ] - try: - r = subprocess.run(pip_cmd, capture_output=True, timeout=600) - except subprocess.TimeoutExpired: - self.logger.warning( - "Verifier helper package install timed out after 600s" - ) - return - except FileNotFoundError as e: - self.logger.warning( - f"pip not found for verifier helper install: {e}" - ) - return - except Exception as e: - self.logger.warning( - f"Verifier helper install raised: {type(e).__name__}: {e}" - ) - return - - if r.returncode != 0: - stderr_tail = r.stderr.decode(errors="replace")[-400:] - self.logger.warning( - f"Verifier helper install failed (rc={r.returncode}); " - f"scripts must restrict themselves to stdlib. " - f"stderr tail: {stderr_tail}" - ) - return - - # Pip can exit 0 yet land packages where the runtime Python can't - # see them — verify by actually importing. - if self._smoke_check(smoke_cmd): - _VERIFIER_PACKAGES_INSTALLED = True - self.logger.info( - f"Verifier helper packages ready: " - f"{list(_VERIFIER_BASE_PACKAGES)} (via {sys.executable})" - ) - else: - self.logger.warning( - "Verifier helper packages installed but not importable " - "under sys.executable; scripts will see ImportError." - ) + _VERIFIER_PACKAGES_INSTALLED = self._install_verifier_helpers() + + def _install_verifier_helpers(self) -> bool: + """Install base helper packages if missing; True iff importable after.""" + smoke_cmd = [sys.executable, "-c", "import " + ", ".join(_VERIFIER_BASE_IMPORTS)] + if self._smoke_check(smoke_cmd): + self.logger.info( + f"Verifier helper packages already importable under {sys.executable}" + ) + return True + if not self._pip_install_base(): + return False + if self._smoke_check(smoke_cmd): + self.logger.info( + f"Verifier helper packages ready: {list(_VERIFIER_BASE_PACKAGES)} " + f"(via {sys.executable})" + ) + return True + self.logger.warning( + "Verifier helper packages installed but not importable under " + "sys.executable; scripts will see ImportError." + ) + return False + + def _pip_install_base(self) -> bool: + """Run ``pip install`` for the verifier base packages; True on rc==0.""" + cmd = [ + sys.executable, "-m", "pip", "install", + *_PIP_INSTALL_FLAGS, *_VERIFIER_BASE_PACKAGES, + ] + r = self._run_pip(cmd, _BASE_INSTALL_TIMEOUT_SECONDS, list(_VERIFIER_BASE_PACKAGES)) + if r is None: + return False + if r.returncode != 0: + tail = r.stderr.decode(errors="replace")[-_STDERR_TAIL_BYTES:] + self.logger.warning( + f"Verifier helper install failed (rc={r.returncode}); " + f"scripts must restrict themselves to stdlib. stderr tail: {tail}" + ) + return False + return True @staticmethod - def _smoke_check(cmd: list[str], timeout: float = 15.0) -> bool: - """Return True iff *cmd* exits 0 within *timeout*. - - Args: - cmd: Command and arguments to invoke via ``subprocess.run``. - timeout: Wall-clock seconds before treating the run as a failure. - - Returns: - True when the command exits with status 0; False otherwise. - """ + def _smoke_check(cmd: list[str], timeout: float = _RUNNER_SMOKE_TIMEOUT) -> bool: + """Return True iff *cmd* exits 0 within *timeout*.""" try: r = subprocess.run(cmd, capture_output=True, timeout=timeout) except Exception: @@ -637,117 +548,385 @@ def _smoke_check(cmd: list[str], timeout: float = 15.0) -> bool: # ------------------------------------------------------------------ def _run_verifier(self, uuid: str, claim_id: str, code: str) -> dict[str, Any]: - """Execute a single verifier script in the agents' workspace. - - Args: - uuid: Workflow identifier (used to name the scratch directory). - claim_id: Identifier of the claim being verified. - code: Python source code of the verifier script. + """Execute one verifier script and return the parsed/normalised result.""" + runner = self._build_runner(uuid) + execution_id = f"verify_{claim_id}" + thread_timeout = self.verifier_timeout + _RUNNER_EXTRA_TIMEOUT + try: + result = _run_coro_sync( + lambda: runner.execute(code, execution_id=execution_id), + thread_timeout=thread_timeout, + ) + except TimeoutError as e: + return self._error_result("timeout", f"verifier execution did not return in time: {e}") + except Exception as e: + return self._error_result("error", f"verifier execution raised: {type(e).__name__}: {e}") + finally: + self._safe_cleanup(runner) + return self._finalize_run_result(result, claim_id) - Returns: - Dict with ``status``, ``actual``, ``details`` plus raw stdout/stderr - and the underlying execution ``exit_status``. - """ + def _build_runner(self, uuid: str) -> WorkflowRunner: + """Construct a sandboxed ``WorkflowRunner`` scoped to ``uuid``.""" scratch = self._runner_temp_root / uuid scratch.mkdir(parents=True, exist_ok=True) - # Run verifiers under the exact interpreter running Mimosa: that is - # where the verifier helper packages were installed, and it avoids - # depending on a system pythonX.Y being on PATH. Passing this via the - # config (rather than overriding runner._python_cmd after construction) - # ensures WorkflowRunner's construction-time availability check uses - # this interpreter too, instead of failing when no matching - # python_version is found on PATH. - runner_config = RuntimeConfig( + cfg = RuntimeConfig( python_executable=sys.executable, timeout=self.verifier_timeout, temp_dir=scratch, requirements_file=None, use_pty=False, ) - runner = WorkflowRunner(runner_config, execution_dir=str(self.workspace_dir)) - execution_id = f"verify_{claim_id}" - thread_timeout = self.verifier_timeout + 10 - result = None + return WorkflowRunner(cfg, execution_dir=str(self.workspace_dir)) + + def _safe_cleanup(self, runner: WorkflowRunner) -> None: + """Best-effort cleanup of *runner*; never raise.""" try: - result = _run_coro_sync( - lambda: runner.execute(code, execution_id=execution_id), - thread_timeout=thread_timeout, - ) - except TimeoutError as e: - return { - "status": "error", - "actual": None, - "details": f"verifier execution did not return in time: {e}", - "raw_stdout": "", - "raw_stderr": "", - "exit_status": "timeout", - } + _run_coro_sync(runner.cleanup, thread_timeout=_RUNNER_CLEANUP_TIMEOUT) except Exception as e: - return { - "status": "error", - "actual": None, - "details": f"verifier execution raised: {type(e).__name__}: {e}", - "raw_stdout": "", - "raw_stderr": "", - "exit_status": "error", - } - finally: - try: - _run_coro_sync(runner.cleanup, thread_timeout=15) - except Exception as e: - self.logger.debug(f"verifier runner cleanup failed: {e}") + self.logger.debug(f"verifier runner cleanup failed: {e}") + @staticmethod + def _error_result(exit_tag: str, details: str) -> dict[str, Any]: + """Shape an error exec_result with empty stdout/stderr.""" + return { + "status": "error", + "actual": None, + "details": details, + "raw_stdout": "", + "raw_stderr": "", + "exit_status": exit_tag, + } + + def _finalize_run_result( + self, result: ExecutionResult, claim_id: str + ) -> dict[str, Any]: + """Merge runner result fields onto the parsed stdout dict.""" parsed = self._parse_verifier_stdout(result.stdout, claim_id) + status = result.status parsed.update({ "raw_stdout": result.stdout, "raw_stderr": result.stderr, - "exit_status": result.status.value if isinstance(result.status, ExecutionStatus) else str(result.status), + "exit_status": status.value if isinstance(status, ExecutionStatus) else str(status), }) - if result.status == ExecutionStatus.TIMEOUT: + if status == ExecutionStatus.TIMEOUT: parsed["status"] = "error" - parsed["details"] = (parsed.get("details") or "") + f" (script timed out after {self.verifier_timeout}s)" - elif result.status == ExecutionStatus.FAILED and parsed.get("status") not in ("pass", "fail"): + parsed["details"] = (parsed.get("details") or "") + ( + f" (script timed out after {self.verifier_timeout}s)" + ) + elif status == ExecutionStatus.FAILED and parsed.get("status") not in ("pass", "fail"): parsed["status"] = "error" - parsed["details"] = (parsed.get("details") or "") + f" (script exit code {result.return_code})" + parsed["details"] = (parsed.get("details") or "") + ( + f" (script exit code {result.return_code})" + ) return parsed - @staticmethod - def _parse_verifier_stdout(stdout: str, claim_id: str) -> dict[str, Any]: - """Pull the last JSON line matching ``claim_id`` from the script stdout. + # ------------------------------------------------------------------ + # Bounded retry / recovery for verifier-side failures + # ------------------------------------------------------------------ - Args: - stdout: Raw captured stdout from the verifier script. - claim_id: Identifier the JSON line must reference. + def _run_verifier_with_recovery( + self, + uuid: str, + claim: dict[str, Any], + spec: dict[str, Any], + execution_text: str, + workspace_listing: str, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Run the verifier with one corrective retry on verifier-side failures. - Returns: - Dict with ``status``, ``actual`` and ``details``; status is set to - ``"error"`` when no matching line is found. + Import errors trigger an LLM package check + sandbox install. Code bugs + (or empty install lists) trigger a single regeneration with traceback + feedback. Failures still present after the retry are returned as-is; + the aggregator excludes them from the importance-weighted mean. """ + cid = claim["id"] + exec_result = self._run_verifier(uuid, cid, spec["code"]) + kind = self._classify_exec_failure(exec_result) + if kind == "ok": + return spec, exec_result + self.logger.info( + f"[recovery {cid}] initial run errored as {kind}; " + f"attempting one corrective action" + ) + if kind == "import": + recovered = self._try_install_and_rerun(uuid, claim, spec, exec_result) + if recovered is not None: + return spec, recovered + return self._regenerate_and_rerun( + uuid, claim, spec, exec_result, execution_text, workspace_listing + ) + + def _try_install_and_rerun( + self, + uuid: str, + claim: dict[str, Any], + spec: dict[str, Any], + exec_result: dict[str, Any], + ) -> dict[str, Any] | None: + """Install LLM-vetted packages and re-run; ``None`` falls through to regen.""" + cid = claim["id"] + stderr = exec_result.get("raw_stderr", "") or "" + packages = self._llm_packages_needed_for_claim(uuid, claim, stderr) + if not packages or not self._sandbox_install_packages(packages): + self.logger.info( + f"[recovery {cid}] no installable packages " + f"(LLM returned {packages or 'empty'}); regenerating instead" + ) + return None + self.logger.info(f"[recovery {cid}] installed {packages}; re-running script") + return self._run_verifier(uuid, cid, spec["code"]) + + def _regenerate_and_rerun( + self, + uuid: str, + claim: dict[str, Any], + spec: dict[str, Any], + exec_result: dict[str, Any], + execution_text: str, + workspace_listing: str, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Regenerate the script once with traceback feedback and re-run it.""" + cid = claim["id"] + new_spec = self._regenerate_verifier_with_feedback( + uuid, claim, spec, exec_result, execution_text, workspace_listing + ) + if not (new_spec.get("executable") and new_spec.get("code")): + reason = str(new_spec.get("reason") or "regenerated spec missing code") + self.logger.info( + f"[recovery {cid}] regeneration produced no executable code: {reason}" + ) + return spec, self._attach_giveup_reason(exec_result, reason) + retry = self._run_verifier(uuid, cid, new_spec["code"]) + return new_spec, retry + + @staticmethod + def _attach_giveup_reason( + exec_result: dict[str, Any], reason: str + ) -> dict[str, Any]: + """Return *exec_result* with status forced to error and *reason* appended.""" + details = (exec_result.get("details") or "").strip() + suffix = f"(regeneration gave up: {reason})" + return { + **exec_result, + "status": "error", + "details": f"{details} {suffix}".strip(), + } + + @classmethod + def _classify_exec_failure(cls, exec_result: dict[str, Any]) -> str: + """Bucket a run outcome as ``ok``, ``import``, or ``code_bug``.""" + if exec_result.get("status") != "error": + return "ok" + blob = (exec_result.get("raw_stderr") or "") + "\n" + (exec_result.get("details") or "") + if any(marker in blob for marker in _RECOVERY_IMPORT_MARKERS): + return "import" + return "code_bug" + + def _llm_packages_needed_for_claim( + self, + uuid: str, + claim: dict[str, Any], + stderr: str, + ) -> list[str]: + """Ask the judge which missing pip packages are genuinely required.""" + prompt = self._build_package_check_prompt(claim, stderr) + parsed, err = self._call_judge_for_json( + uuid, f"verifier_pkg_check_{claim['id']}", prompt + ) + if err is not None or not isinstance(parsed, dict): + self.logger.debug( + f"package-need check failed for {claim['id']}: " + f"{err or 'non-dict JSON'}" + ) + return [] + raw = parsed.get("packages") + return self._clean_package_list(raw) if isinstance(raw, list) else [] + + @staticmethod + def _build_package_check_prompt(claim: dict[str, Any], stderr: str) -> str: + """Build the package-need check prompt sent to the judge.""" + lines = (stderr or "").splitlines()[-_RECOVERY_STDERR_FEEDBACK_LINES:] + tail = "\n".join(lines) or "(no traceback available)" + base_pkgs = ", ".join(_VERIFIER_BASE_PACKAGES) + return f""" +A verifier program for ONE atomic claim crashed because it tried to import an unavailable Python package. + +CLAIM: +- id: {claim['id']} +- description: {claim.get('description', '')} + +TRACEBACK (last {_RECOVERY_STDERR_FEEDBACK_LINES} lines): +{tail} + +ALREADY AVAILABLE (do NOT list these): {base_pkgs}, plus the Python standard library. + +QUESTION: are the missing packages STRICTLY required to verify this claim, or could the verifier be rewritten in pure Python using only the available imports? + +Return STRICT JSON only: + {{"packages": ["", ...]}} + +Rules: +- Empty list ({{"packages": []}}) iff the claim can be verified without third-party packages. +- Use pip-install names (``scikit-learn``, not ``sklearn``; ``Pillow``, not ``PIL``). +- Never list a package that is already available. +- At most {_RECOVERY_MAX_INSTALL_PACKAGES} entries. +""" + + @staticmethod + def _clean_package_list(raw: list[Any]) -> list[str]: + """Dedupe and cap the LLM-returned package list; drop already-available ones.""" + already = {p.lower() for p in _VERIFIER_BASE_PACKAGES} + seen: set[str] = set() + cleaned: list[str] = [] + for entry in raw: + if not isinstance(entry, str): + continue + name = entry.strip() + if not name or name.lower() in already or name.lower() in seen: + continue + seen.add(name.lower()) + cleaned.append(name) + if len(cleaned) >= _RECOVERY_MAX_INSTALL_PACKAGES: + break + return cleaned + + def _sandbox_install_packages(self, packages: list[str]) -> bool: + """Pip-install *packages* under ``sys.executable``; True iff rc==0.""" + if not packages: + return False + cmd = [sys.executable, "-m", "pip", "install", *_PIP_INSTALL_FLAGS, *packages] + r = self._run_pip(cmd, _RECOVERY_INSTALL_TIMEOUT_SECONDS, packages) + if r is None: + return False + if r.returncode != 0: + tail = r.stderr.decode(errors="replace")[-_STDERR_TAIL_BYTES:] + self.logger.warning( + f"sandbox install failed (rc={r.returncode}) for {packages}; " + f"stderr tail: {tail}" + ) + return False + self.logger.info(f"sandbox-installed verifier packages: {packages}") + return True + + def _run_pip( + self, + cmd: list[str], + timeout: int, + packages: list[str], + ) -> subprocess.CompletedProcess | None: + """Wrap ``subprocess.run`` for pip; log and return ``None`` on failure.""" + try: + return subprocess.run(cmd, capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired: + self.logger.warning(f"sandbox install timed out after {timeout}s for {packages}") + except FileNotFoundError as e: + self.logger.warning(f"pip not found for verifier helper install: {e}") + except Exception as e: + self.logger.warning(f"sandbox install raised: {type(e).__name__}: {e}") + return None + + def _regenerate_verifier_with_feedback( + self, + uuid: str, + claim: dict[str, Any], + prev_spec: dict[str, Any], + exec_result: dict[str, Any], + execution_text: str, + workspace_listing: str, + ) -> dict[str, Any]: + """Ask the judge to fix the previous script given the traceback.""" + prompt = self._build_regen_prompt(claim, prev_spec, exec_result) + return self._call_and_parse_verifier(uuid, claim, prompt, attempt=2) + + def _build_regen_prompt( + self, + claim: dict[str, Any], + prev_spec: dict[str, Any], + exec_result: dict[str, Any], + ) -> str: + """Build the verifier-regeneration prompt fed with the previous traceback.""" + prev_code = prev_spec.get("code", "") + stderr_lines = (exec_result.get("raw_stderr") or "").splitlines()[ + -_RECOVERY_STDERR_FEEDBACK_LINES: + ] + stderr_tail = "\n".join(stderr_lines) or "(no stderr captured)" + details = exec_result.get("details", "") or "" + previews = self._render_relevant_previews(claim.get("likely_relevant_files", [])) + packages = ", ".join(_VERIFIER_BASE_PACKAGES) + return f""" +Your previous verifier script for ONE atomic claim crashed at runtime. Fix it and resubmit the FULL corrected script. + +CLAIM: +- id: {claim['id']} +- importance: {claim.get('importance', self._DEFAULT_CLAIM_IMPORTANCE)} (1-10; 10 = literal deliverable) +- description: {claim['description']} +- likely_relevant_files: {claim.get('likely_relevant_files', [])} + +RELEVANT FILE PREVIEWS: +{previews} + +PREVIOUS SCRIPT (do not repeat its mistake): +{prev_code} + +RUNTIME ERROR DETAILS: {details} + +STDERR (last {_RECOVERY_STDERR_FEEDBACK_LINES} lines): +{stderr_tail} + +INSTRUCTIONS: +- Diagnose the failure from the traceback above and emit a corrected script. +- Keep the output contract: print EXACTLY ONE JSON line to stdout shaped + {{"claim_id": "{claim['id']}", "status": "pass"|"fail"|"error", "actual": , "details": ""}}. +- Catch your own exceptions inside the script and emit status="error" — never let the script raise. +- Read files with relative paths (cwd is the workspace). +- AVAILABLE IMPORTS: {packages}. Do NOT introduce any other third-party imports. +- If the previous failure was an ImportError, rewrite without that package using the available imports and the standard library. + +Return STRICT JSON only: + {{"executable": true, "code": ""}} +""" + + # ------------------------------------------------------------------ + # Stdout parsing + # ------------------------------------------------------------------ + + @staticmethod + def _parse_verifier_stdout(stdout: str, claim_id: str) -> dict[str, Any]: + """Pull the last JSON line matching ``claim_id`` from script stdout.""" if not stdout: return {"status": "error", "actual": None, "details": "no stdout from verifier"} for line in reversed(stdout.splitlines()): - line = line.strip() - if not line.startswith("{"): - continue - try: - obj = json.loads(line) - except json.JSONDecodeError: - continue + obj = _VerifierPerClaimMixin._try_json(line.strip()) if isinstance(obj, dict) and obj.get("claim_id") == claim_id: - status = obj.get("status") - if status not in ("pass", "fail", "error"): - return { - "status": "error", - "actual": obj.get("actual"), - "details": f"unrecognised status '{status}' from verifier", - } - return { - "status": status, - "actual": obj.get("actual"), - "details": str(obj.get("details", "")), - } + return _VerifierPerClaimMixin._normalise_status(obj) return {"status": "error", "actual": None, "details": "no matching JSON line in verifier stdout"} + @staticmethod + def _try_json(line: str) -> Any: + """Return ``json.loads(line)`` or ``None`` when not a JSON object.""" + if not line.startswith("{"): + return None + try: + return json.loads(line) + except json.JSONDecodeError: + return None + + @staticmethod + def _normalise_status(obj: dict[str, Any]) -> dict[str, Any]: + """Reduce a verifier-line dict to the canonical (status, actual, details) shape.""" + status = obj.get("status") + if status not in ("pass", "fail", "error"): + return { + "status": "error", + "actual": obj.get("actual"), + "details": f"unrecognised status '{status}' from verifier", + } + return { + "status": status, + "actual": obj.get("actual"), + "details": str(obj.get("details", "")), + } + # ------------------------------------------------------------------ # Per-claim scoring # ------------------------------------------------------------------ @@ -758,20 +937,10 @@ def _score_executable( spec: dict[str, Any], exec_result: dict[str, Any], ) -> dict[str, Any]: - """Convert an executable-verifier run into a scored result dict. - - Args: - claim: Normalised claim dict (unused but kept for signature parity). - spec: Verifier spec returned by the judge (unused here). - exec_result: Dict produced by ``_run_verifier``. - - Returns: - Scored claim dict with ``score`` 1.0 for ``pass`` and 0.0 otherwise. - """ + """Convert an executable-verifier run into a scored result dict.""" status = exec_result.get("status") - score = 1.0 if status == "pass" else 0.0 return { - "score": score, + "score": 1.0 if status == "pass" else 0.0, "verifier_kind": "executable", "status": status, "actual": exec_result.get("actual"), @@ -781,6 +950,8 @@ def _score_executable( "exit_status": exec_result.get("exit_status", ""), } + _SOFT_VERDICT_SCORE = {"pass": 1.0, "unsure": 0.5, "fail": 0.0} + def _score_soft( self, uuid: str, @@ -790,25 +961,40 @@ def _score_soft( reason: str, grounding: str = "", ) -> dict[str, Any]: - """Narrow LLM verdict for one non-executable claim, anchored on grounding. - - Args: - uuid: Workflow identifier (used for judge calls). - claim: Normalised claim dict. - execution_text: Agent narration / produced output text. - workspace_listing: Rendered listing of workspace files. - reason: Justification string for why the claim is non-executable. - grounding: Optional peer-reviewed literature grounding block. - - Returns: - Scored claim dict with ``score`` in {0.0, 0.5, 1.0} and a - ``rationale`` from the judge. - """ - relevant_previews = self._render_relevant_previews( - claim.get("likely_relevant_files", []) + """Narrow LLM verdict for one non-executable claim, anchored on grounding.""" + prompt = self._build_soft_check_prompt( + claim, execution_text, workspace_listing, reason, grounding ) + data, err = self._call_judge_for_json( + uuid, f"verifier_soft_{claim['id']}", prompt + ) + if err is not None or not isinstance(data, dict): + return { + "score": 0.0, "verifier_kind": "soft", "status": "error", + "details": f"soft check failed: {err or 'verdict JSON not an object'}", + "rationale": "", + } + verdict = data.get("verdict", "unsure") + rationale = str(data.get("rationale", "")) + return { + "score": self._SOFT_VERDICT_SCORE.get(verdict, 0.5), + "verifier_kind": "soft", + "status": verdict, + "details": rationale, + "rationale": rationale, + } + + def _build_soft_check_prompt( + self, + claim: dict[str, Any], + execution_text: str, + workspace_listing: str, + reason: str, + grounding: str, + ) -> str: + """Build the soft-check (non-executable verdict) prompt.""" grounding_block = grounding.strip() if grounding else "(no literature grounding available)" - prompt = f""" + return f""" You are checking ONE claim from a multi-agent workflow. The claim is not executable in code; please judge it against the concrete context below. @@ -845,49 +1031,64 @@ def _score_soft( Return STRICT JSON: {{"verdict": "pass" | "unsure" | "fail", "rationale": ""}} """ - data, err = self._call_judge_for_json( - uuid, f"verifier_soft_{claim['id']}", prompt - ) - if err is not None or not isinstance(data, dict): - # No usable verdict — treat as non-signal so it neither rewards nor - # punishes the workflow. Aggregator excludes errors from the mean. - return { - "score": 0.0, - "verifier_kind": "soft", - "status": "error", - "details": f"soft check failed: {err or 'verdict JSON not an object'}", - "rationale": "", - } - verdict = data.get("verdict", "unsure") - score = {"pass": 1.0, "unsure": 0.5, "fail": 0.0}.get(verdict, 0.5) - return { - "score": score, - "verifier_kind": "soft", - "status": verdict, - "details": str(data.get("rationale", "")), - "rationale": str(data.get("rationale", "")), - } if __name__ == "__main__": expected = { + # Top-level orchestration "_verify_claim", + "_resolve_relevant_files", + "_print_claim_header", + "_print_claim_verdict", + "_run_and_score", + "_print_executable_summary", + "_run_soft_branch", + # File selection "_llm_select_files", "_build_select_files_prompt", + # Spec generation "_generate_verifier", + "_build_verifier_prompt", "_call_and_parse_verifier", "_select_files_and_generate_spec", "_generate_specs_parallel", + "_collect_spec_future", + # Base-package install "_ensure_verifier_packages", + "_install_verifier_helpers", + "_pip_install_base", "_smoke_check", + # Execution "_run_verifier", + "_build_runner", + "_safe_cleanup", + "_error_result", + "_finalize_run_result", + # Recovery + "_run_verifier_with_recovery", + "_try_install_and_rerun", + "_regenerate_and_rerun", + "_attach_giveup_reason", + "_classify_exec_failure", + "_llm_packages_needed_for_claim", + "_build_package_check_prompt", + "_clean_package_list", + "_sandbox_install_packages", + "_run_pip", + "_regenerate_verifier_with_feedback", + "_build_regen_prompt", + # Stdout parsing "_parse_verifier_stdout", + "_try_json", + "_normalise_status", + # Scoring "_score_executable", "_score_soft", + "_build_soft_check_prompt", } actual = {n for n in dir(_VerifierPerClaimMixin) if not n.startswith("__")} missing = expected - actual assert not missing, f"per-claim mixin missing methods: {missing}" - # _run_coro_sync is module-level — sanity-check it's reachable too. assert callable(_run_coro_sync), "_run_coro_sync missing at module level" + assert callable(_run_coro_in_worker), "_run_coro_in_worker missing at module level" print("verifier_per_claim: smoke ok") From 0c3189797d707ccbf15801e54971df0da3b4e77c Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 17:25:44 +0200 Subject: [PATCH 22/71] rm : __name__ unused --- .../getting-started/configuration/index.html | 2 +- sources/core/evaluators/verifier_per_claim.py | 63 +------------------ sources/core/selection.py | 2 +- 3 files changed, 3 insertions(+), 64 deletions(-) diff --git a/docs/site/getting-started/configuration/index.html b/docs/site/getting-started/configuration/index.html index 220c8b7..54ed768 100644 --- a/docs/site/getting-started/configuration/index.html +++ b/docs/site/getting-started/configuration/index.html @@ -2276,7 +2276,7 @@

Runner limits runner_default_python_version -3.10 +3.12 Python version inside the sandbox. diff --git a/sources/core/evaluators/verifier_per_claim.py b/sources/core/evaluators/verifier_per_claim.py index 7968043..b53ba1a 100644 --- a/sources/core/evaluators/verifier_per_claim.py +++ b/sources/core/evaluators/verifier_per_claim.py @@ -1030,65 +1030,4 @@ def _build_soft_check_prompt( for the absence of grounding. Return STRICT JSON: {{"verdict": "pass" | "unsure" | "fail", "rationale": ""}} -""" - - -if __name__ == "__main__": - expected = { - # Top-level orchestration - "_verify_claim", - "_resolve_relevant_files", - "_print_claim_header", - "_print_claim_verdict", - "_run_and_score", - "_print_executable_summary", - "_run_soft_branch", - # File selection - "_llm_select_files", - "_build_select_files_prompt", - # Spec generation - "_generate_verifier", - "_build_verifier_prompt", - "_call_and_parse_verifier", - "_select_files_and_generate_spec", - "_generate_specs_parallel", - "_collect_spec_future", - # Base-package install - "_ensure_verifier_packages", - "_install_verifier_helpers", - "_pip_install_base", - "_smoke_check", - # Execution - "_run_verifier", - "_build_runner", - "_safe_cleanup", - "_error_result", - "_finalize_run_result", - # Recovery - "_run_verifier_with_recovery", - "_try_install_and_rerun", - "_regenerate_and_rerun", - "_attach_giveup_reason", - "_classify_exec_failure", - "_llm_packages_needed_for_claim", - "_build_package_check_prompt", - "_clean_package_list", - "_sandbox_install_packages", - "_run_pip", - "_regenerate_verifier_with_feedback", - "_build_regen_prompt", - # Stdout parsing - "_parse_verifier_stdout", - "_try_json", - "_normalise_status", - # Scoring - "_score_executable", - "_score_soft", - "_build_soft_check_prompt", - } - actual = {n for n in dir(_VerifierPerClaimMixin) if not n.startswith("__")} - missing = expected - actual - assert not missing, f"per-claim mixin missing methods: {missing}" - assert callable(_run_coro_sync), "_run_coro_sync missing at module level" - assert callable(_run_coro_in_worker), "_run_coro_in_worker missing at module level" - print("verifier_per_claim: smoke ok") +""" \ No newline at end of file diff --git a/sources/core/selection.py b/sources/core/selection.py index f06e6f9..2e79b34 100644 --- a/sources/core/selection.py +++ b/sources/core/selection.py @@ -76,7 +76,7 @@ def __init__( strategy: str | SelectionStrategy = SelectionStrategy.QUALITY_DIVERSITY, population_size: int = 25, novelty_k_neighbours: int = 10, - novelty_weight: float = 0.4, + novelty_weight: float = 0.25, admit_threshold: float = 0.3, max_children: int = MAX_CHILDREN_PER_PARENT, ) -> None: From f03c99b47407b12ae1c902ccd4f9a5ff04ad7ab5 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 17:49:11 +0200 Subject: [PATCH 23/71] feat(selection,qd): genotype-embedding novelty + length penalty --- config.py | 29 +++ sources/core/code_features.py | 81 +++---- sources/core/evolution_engine.py | 6 +- sources/core/genotype_embedding.py | 175 +++++++++++++++ sources/core/selection.py | 344 +++++++++++++++++++---------- tests/selection_test.py | 127 ++++++++--- 6 files changed, 572 insertions(+), 190 deletions(-) create mode 100644 sources/core/genotype_embedding.py diff --git a/config.py b/config.py index 9ea52cc..3fb1fad 100644 --- a/config.py +++ b/config.py @@ -71,6 +71,17 @@ def __init__(self): self.learned_score_threshold = 0.9 self.max_learning_evolve_iterations = 20 + # QD novelty + length penalty (open-ended modes) + # novelty_comparison: "archive_knn" (default) compares against the + # k-NN archive; "previous_n" compares only against the last N + # produced genotypes (lighter-weight, no archive memory). + self.novelty_comparison: str = "archive_knn" + self.novelty_previous_n: int = 5 + # Length penalty: genotype size at which the penalty starts to + # grow; lambda is small so it only breaks near-ties. + self.length_penalty_baseline_chars: int = 5000 + self.length_penalty_lambda: float = 0.05 + # evaluation concurrency settings self.max_concurrent_eval_tasks: int = 2 # Number of concurrent tasks for CSV evaluation mode @@ -215,6 +226,10 @@ def jsonify( "max_tokens": self.max_tokens, "learned_score_threshold": self.learned_score_threshold, "max_learning_evolve_iterations": self.max_learning_evolve_iterations, + "novelty_comparison": self.novelty_comparison, + "novelty_previous_n": self.novelty_previous_n, + "length_penalty_baseline_chars": self.length_penalty_baseline_chars, + "length_penalty_lambda": self.length_penalty_lambda, "schema_code_path": self.schema_code_path, "smolagent_factory_code_path": self.smolagent_factory_code_path, "runs_capsule_dir": self.runs_capsule_dir, @@ -257,6 +272,16 @@ def from_json(self, data: dict[str, Any]) -> None: self.max_learning_evolve_iterations = data.get( "max_learning_evolve_iterations", self.max_learning_evolve_iterations ) + self.novelty_comparison = data.get("novelty_comparison", self.novelty_comparison) + self.novelty_previous_n = int( + data.get("novelty_previous_n", self.novelty_previous_n) + ) + self.length_penalty_baseline_chars = int( + data.get("length_penalty_baseline_chars", self.length_penalty_baseline_chars) + ) + self.length_penalty_lambda = float( + data.get("length_penalty_lambda", self.length_penalty_lambda) + ) self.schema_code_path = data.get("schema_code_path", self.schema_code_path) self.smolagent_factory_code_path = data.get( "smolagent_factory_code_path", self.smolagent_factory_code_path @@ -319,6 +344,10 @@ def __str__(self) -> str: lines.append(f" max_tokens={self.max_tokens}") lines.append(f" learned_score_threshold={self.learned_score_threshold}") lines.append(f" max_learning_evolve_iterations={self.max_learning_evolve_iterations}") + lines.append(f" novelty_comparison={self.novelty_comparison}") + lines.append(f" novelty_previous_n={self.novelty_previous_n}") + lines.append(f" length_penalty_baseline_chars={self.length_penalty_baseline_chars}") + lines.append(f" length_penalty_lambda={self.length_penalty_lambda}") lines.append(f" max_concurrent_eval_tasks={self.max_concurrent_eval_tasks}") lines.append(f" schema_code_path={self.schema_code_path}") lines.append(f" smolagent_factory_code_path={self.smolagent_factory_code_path}") diff --git a/sources/core/code_features.py b/sources/core/code_features.py index e2ab2b9..8e43061 100644 --- a/sources/core/code_features.py +++ b/sources/core/code_features.py @@ -1,53 +1,44 @@ -"""AST-derived behavioural features for the QD descriptor. +"""Genotype-embedding behaviour descriptor for the QD archive. -The descriptor must be orthogonal to fitness so the archive can -separate "different ways of being good" from "different ways of being -mediocre". These features read the workflow genotype source only — -never reward, cost, or iteration count. -""" +The descriptor must be orthogonal to fitness so the archive can separate +"different ways of being good" from "different ways of being mediocre". +This module exposes the workflow genotype's unit-norm code embedding as +the QD behaviour descriptor — two workflows whose generated code is +semantically similar collapse to the same point, so QD keeps only +genotypes that explored a different approach. -import ast +Backend selection lives in :mod:`sources.core.genotype_embedding`; this +file is only the QD-facing shim. +""" -_AGENT_CTORS: frozenset[str] = frozenset( - {"SmolAgentFactory", "CodeAgent", "ToolCallingAgent", "MultiStepAgent"} -) -_EDGE_METHODS: frozenset[str] = frozenset({"add_edge", "add_conditional_edges"}) +from .genotype_embedding import embed_genotype -_SCALES: tuple[float, float, float, float] = (10.0, 10.0, 10.0, 5000.0) -DESCRIPTOR_DIM: int = 4 +def genotype_embedding_descriptor(code: str | None) -> list[float] | None: + """Return the unit-norm genotype embedding for QD novelty. -def extract_code_features(code: str | None) -> list[float]: - """Return a normalised 4-vector parsed from the workflow source. + Args: + code: Workflow source code as a string. - Axes: ``[n_agents, n_edges, n_branches, prompt_chars]`` each - divided by a fixed scale so contributions to k-NN Euclidean - distance are comparable. Missing or unparseable code yields a - zero vector (treated by the gate as low-novelty). + Returns: + L2-normalised embedding as a plain ``list[float]``. ``None`` for + degenerate inputs (missing source, empty string, or backend + failure) so the selection layer can fall through to a neutral + novelty signal — never to max-novel. """ - if not code: - return [0.0] * DESCRIPTOR_DIM - try: - tree = ast.parse(code) - except SyntaxError: - return [0.0] * DESCRIPTOR_DIM - - n_agents = 0 - n_edges = 0 - n_branches = 0 - prompt_chars = 0 - - for node in ast.walk(tree): - if isinstance(node, ast.Call): - fn = node.func - if isinstance(fn, ast.Name) and fn.id in _AGENT_CTORS: - n_agents += 1 - elif isinstance(fn, ast.Attribute) and fn.attr in _EDGE_METHODS: - n_edges += 1 - elif isinstance(node, (ast.If, ast.For, ast.While)): - n_branches += 1 - elif isinstance(node, ast.Constant) and isinstance(node.value, str): - prompt_chars += len(node.value) - - raw = (n_agents, n_edges, n_branches, prompt_chars) - return [r / s for r, s in zip(raw, _SCALES)] + vec = embed_genotype(code) + if vec is None: + return None + return [float(x) for x in vec.tolist()] + + +if __name__ == "__main__": + assert genotype_embedding_descriptor(None) is None + assert genotype_embedding_descriptor("") is None + out = genotype_embedding_descriptor("def workflow(state): return state") + if out is not None: + norm = sum(x * x for x in out) ** 0.5 + assert abs(norm - 1.0) < 1e-4, f"expected unit norm, got {norm}" + print(f"smoke OK: unit-norm descriptor of dim {len(out)}") + else: + print("smoke OK: degenerate path (backend unavailable)") diff --git a/sources/core/evolution_engine.py b/sources/core/evolution_engine.py index ed49964..501a09b 100644 --- a/sources/core/evolution_engine.py +++ b/sources/core/evolution_engine.py @@ -96,7 +96,11 @@ def __init__( strategy="qd", # quality-diversity selection population_size=50, # max individuals to keep in the selection pool novelty_k_neighbours=15, - novelty_weight=0.25 + novelty_weight=0.25, + novelty_comparison=getattr(config, "novelty_comparison", "archive_knn"), + previous_n=getattr(config, "novelty_previous_n", 5), + length_penalty_baseline_chars=getattr(config, "length_penalty_baseline_chars", 5000), + length_penalty_lambda=getattr(config, "length_penalty_lambda", 0.05), ) self.initial_population = 2 # number of initial random workflows before enabling mutation diff --git a/sources/core/genotype_embedding.py b/sources/core/genotype_embedding.py new file mode 100644 index 0000000..3bb4171 --- /dev/null +++ b/sources/core/genotype_embedding.py @@ -0,0 +1,175 @@ +"""Pluggable code-genotype embedding for QD novelty. + +Two workflows whose generated code is semantically similar collapse to +the same point in embedding space — keeping both is redundant. Ones that +explored different approaches land far apart. The QD novelty axis reads +"how different is this code" by mean cosine distance to prior genotypes +in this space. + +Default backend: local ``all-MiniLM-L6-v2`` (sentence-transformers) — +no network at runtime, deterministic, free. Optional backend: OpenAI +``text-embedding-3-small`` when both +``MIMOSA_GENOTYPE_EMBEDDING_BACKEND=openai`` and ``OPENAI_API_KEY`` are +set. A degenerate input (``None``, empty, or a backend error) returns +``None`` so the selection layer can treat the missing signal as +"no novelty" (neutral) instead of max-novel — otherwise broken offspring +would be rewarded. +""" + +import hashlib +import logging +import os +from typing import Protocol + +import numpy as np + +logger = logging.getLogger(__name__) + +_DEFAULT_LOCAL_MODEL: str = "all-MiniLM-L6-v2" +_DEFAULT_OPENAI_MODEL: str = "text-embedding-3-small" + + +class _Embedder(Protocol): + """Minimal interface for a text-to-unit-vector backend.""" + + def encode(self, text: str) -> np.ndarray: ... + + +def _l2_normalize(vec: np.ndarray) -> np.ndarray: + """Return ``vec`` rescaled to unit L2 norm; zero vectors pass through.""" + norm = float(np.linalg.norm(vec)) + if norm <= 0.0: + return vec + return vec / norm + + +class _LocalMiniLMEmbedder: + """sentence-transformers all-MiniLM-L6-v2 backend.""" + + def __init__(self, model_name: str = _DEFAULT_LOCAL_MODEL) -> None: + from sentence_transformers import SentenceTransformer + self._model = SentenceTransformer(model_name, token=False) + + def encode(self, text: str) -> np.ndarray: + """Encode ``text`` and return an L2-normalised float32 vector.""" + raw = self._model.encode(text, convert_to_numpy=True, show_progress_bar=False) + return _l2_normalize(np.asarray(raw, dtype=np.float32)) + + +class _OpenAIEmbedder: + """OpenAI text-embedding-3 backend; activated by env vars.""" + + def __init__(self, model_name: str = _DEFAULT_OPENAI_MODEL) -> None: + from openai import OpenAI + self._client = OpenAI() + self._model_name = model_name + + def encode(self, text: str) -> np.ndarray: + """Encode ``text`` via the API and return an L2-normalised vector.""" + resp = self._client.embeddings.create(model=self._model_name, input=text) + raw = np.asarray(resp.data[0].embedding, dtype=np.float32) + return _l2_normalize(raw) + + +class GenotypeEmbedder: + """Process-local genotype embedder with SHA-1 cache. + + Identical code text is embedded once per process, so the QD inner + loop stays cheap even when archive refresh walks dozens of members. + The backend is constructed lazily so importing this module never + triggers model loading. + """ + + def __init__(self, backend: _Embedder | None = None) -> None: + """Bind a backend; defer instantiation if none is supplied.""" + self._backend: _Embedder | None = backend + self._cache: dict[str, np.ndarray] = {} + + def _ensure_backend(self) -> _Embedder: + """Return the bound backend, instantiating the default on first call.""" + if self._backend is None: + self._backend = _select_default_backend() + return self._backend + + def embed(self, genotype: str | None) -> np.ndarray | None: + """Embed a genotype source to a unit-norm vector. + + Args: + genotype: Workflow source code; ``None``, empty, or + non-string inputs are treated as degenerate. + + Returns: + Unit-norm ``np.ndarray`` of shape ``(d,)`` on success; ``None`` + when the input is degenerate or the backend errors out. + """ + if not isinstance(genotype, str) or not genotype: + return None + key = hashlib.sha1(genotype.encode("utf-8")).hexdigest() + cached = self._cache.get(key) + if cached is not None: + return cached + try: + vec = self._ensure_backend().encode(genotype) + except Exception as exc: + logger.warning("genotype embed failed: %s", exc) + return None + if vec is None or vec.size == 0 or not np.isfinite(vec).all(): + return None + self._cache[key] = vec + return vec + + +def _select_default_backend() -> _Embedder: + """Pick MiniLM (default) or OpenAI based on environment variables.""" + backend = os.environ.get("MIMOSA_GENOTYPE_EMBEDDING_BACKEND", "local").strip().lower() + if backend == "openai" and os.environ.get("OPENAI_API_KEY"): + try: + return _OpenAIEmbedder() + except Exception as exc: + logger.warning("OpenAI embedder unavailable, falling back to MiniLM: %s", exc) + return _LocalMiniLMEmbedder() + + +_default_embedder: GenotypeEmbedder | None = None + + +def get_default_embedder() -> GenotypeEmbedder: + """Module-level singleton — instantiated lazily on first call.""" + global _default_embedder + if _default_embedder is None: + _default_embedder = GenotypeEmbedder() + return _default_embedder + + +def embed_genotype(genotype: str | None) -> np.ndarray | None: + """Convenience wrapper around the module-level default embedder.""" + return get_default_embedder().embed(genotype) + + +def reset_default_embedder() -> None: + """Drop the cached singleton — test helper, never used at runtime.""" + global _default_embedder + _default_embedder = None + + +if __name__ == "__main__": + class _StubBackend: + """Deterministic stub: SHA-1 bytes → 20-dim float vector.""" + + def encode(self, text: str) -> np.ndarray: + digest = hashlib.sha1(text.encode("utf-8")).digest() + arr = np.frombuffer(digest, dtype=np.uint8).astype(np.float32) + return _l2_normalize(arr) + + emb = GenotypeEmbedder(backend=_StubBackend()) + a = emb.embed("def x(): return 1") + b = emb.embed("def x(): return 1") + c = emb.embed("def y(): return 2") + assert a is not None and b is not None and c is not None + assert np.allclose(a, b), "cache should return identical vector" + assert not np.allclose(a, c), "different code → different vector" + assert abs(float(np.linalg.norm(a)) - 1.0) < 1e-5, "embedding must be unit norm" + assert emb.embed(None) is None + assert emb.embed("") is None + assert emb.embed(123) is None # type: ignore[arg-type] + print("smoke OK: deterministic, cached, unit-norm embeddings") diff --git a/sources/core/selection.py b/sources/core/selection.py index 2e79b34..9a78892 100644 --- a/sources/core/selection.py +++ b/sources/core/selection.py @@ -3,6 +3,12 @@ Supports two modes: 1. Greedy : validates that the latest run improved over recent history. 2. Open-ended : maintains a population archive, uses novelty + quality to decide which individuals survive + +Novelty uses the genotype embedding from :mod:`sources.core.code_features`: +two workflows whose generated code is semantically close collapse onto +the same point and are redundant; ones that explored different +approaches land far apart and both survive. A length-penalty term in +``qd_score`` discourages runaway code growth without overriding ranking. """ import logging @@ -13,14 +19,15 @@ from enum import Enum from typing import Any -from .failure_fingerprint import ( - DESCRIPTOR_DIM as _DESCRIPTOR_DIM, - failure_fingerprint_from_state_result, - neutral_fingerprint, -) +from .code_features import genotype_embedding_descriptor MAX_CHILDREN_PER_PARENT = 2 +# Comparison-set modes for novelty. +NOVELTY_ARCHIVE_KNN = "archive_knn" +NOVELTY_PREVIOUS_N = "previous_n" + + class SelectionStrategy(Enum): """Available selection strategies for the evolution loop.""" GREEDY = "greedy" # Accept only if strictly better (current behaviour) @@ -38,11 +45,13 @@ class PopulationMember: reward: Capped reward used for greedy comparisons. cost: Monetary or compute cost spent to produce this member. uuid: Workflow UUID; ``None`` for members without on-disk artifacts. - behaviour_descriptor: Fixed-length failure-fingerprint vector used - for novelty distance — centered per-source pass rates from the - verifier, produced by :mod:`sources.core.failure_fingerprint`. - novelty_score: k-NN novelty against the rest of the archive. - qd_score: Combined quality-diversity score. + behaviour_descriptor: Unit-norm genotype embedding used for the + cosine-distance novelty signal. Empty list when no embedding + could be produced (treated as neutral by the selection layer). + genotype_chars: Raw character length of the workflow source — + used for the length-penalty term in ``qd_score``. + novelty_score: Mean cosine distance to the current comparison set. + qd_score: Combined quality-diversity score with length penalty. reward_uncapped: Base + info-bonus − cheat, with no hard-fail cap. created_at: Wall-clock timestamp of construction. """ @@ -51,9 +60,10 @@ class PopulationMember: cost: float uuid: str | None = None behaviour_descriptor: list[float] = field(default_factory=list) + genotype_chars: int = 0 novelty_score: float = 0.0 - qd_score: float = 0.0 # combined quality-diversity score - reward_uncapped: float = 0.0 # base+info-bonus−cheat, no hard-fail cap + qd_score: float = 0.0 + reward_uncapped: float = 0.0 created_at: datetime = field(default_factory=datetime.now) @@ -67,7 +77,8 @@ class SelectionPressure: In **open-ended** modes it maintains a population archive and uses novelty / quality-diversity scoring so that low-performing but behaviourally novel runs can survive and potentially lead to better - solutions later. + solutions later. Novelty is ``1 − cosine_similarity`` between unit-norm + genotype embeddings. """ def __init__( @@ -79,6 +90,10 @@ def __init__( novelty_weight: float = 0.25, admit_threshold: float = 0.3, max_children: int = MAX_CHILDREN_PER_PARENT, + novelty_comparison: str = NOVELTY_ARCHIVE_KNN, + previous_n: int = 5, + length_penalty_baseline_chars: int = 5000, + length_penalty_lambda: float = 0.05, ) -> None: """Configure thresholds and the active selection strategy. @@ -86,11 +101,20 @@ def __init__( min_improvement_threshold: Minimum relative improvement for greedy mode (5% default). strategy: Selection strategy (greedy | tournament | novelty | qd). population_size: Max individuals kept in the archive (for open-ended modes). - novelty_k_neighbours: k for k-nearest novelty calculation. + novelty_k_neighbours: k for k-nearest novelty calculation (archive_knn mode). novelty_weight: Weight of novelty vs quality in QD score (0 = pure quality, 1 = pure novelty). admit_threshold: Minimum qd_score for admission when greedy validity fails. max_children: Maximum offspring drawn from a single parent before its inverse-child-count weight pushes it below peers. + novelty_comparison: Comparison-set mode — ``"archive_knn"`` (k-NN + against the archive, default) or ``"previous_n"`` (mean + distance to the N most recently produced genotypes). + previous_n: Window size for ``"previous_n"`` mode. + length_penalty_baseline_chars: Genotype size at which the length + penalty starts to grow above zero. + length_penalty_lambda: Strength of the length penalty term + subtracted from ``qd_score``. Kept conservative so it only + breaks near-ties. """ self.logger = logging.getLogger(__name__) self.min_improvement_threshold = min_improvement_threshold @@ -104,14 +128,19 @@ def __init__( self.novelty_weight = novelty_weight self.admit_threshold = admit_threshold self.max_children = max_children + self.novelty_comparison = ( + novelty_comparison if novelty_comparison in (NOVELTY_ARCHIVE_KNN, NOVELTY_PREVIOUS_N) + else NOVELTY_ARCHIVE_KNN + ) + self.previous_n = max(1, int(previous_n)) + self.length_baseline_chars = max(1, int(length_penalty_baseline_chars)) + self.length_lambda = float(length_penalty_lambda) - # Population archive for open-ended modes self._archive: list[PopulationMember] = [] - # Count of offspring rejected by the admit gate (S2 telemetry) + self._previous_descriptors: list[list[float]] = [] self._n_admit_rejected: int = 0 - # UUID evicted by the most recent admission, or ``None``. Cleared - # at the start of each ``_validate_open_ended`` call so callers - # can surface it in their archive log without stale carryover. + # Reset each call to _validate_open_ended so log lines surface the + # eviction from *that* admission only, not a stale carryover. self._last_evicted_uuid: str | None = None def validate_survivor( @@ -232,7 +261,6 @@ def select_parents( return [], False n_parents = max(n_parents, 2) - # Decide crossover vs mutation do_crossover = ( len(candidates) >= 2 and random.random() < crossover_rate @@ -241,7 +269,7 @@ def select_parents( parent = self.select_parent(candidates, child_counts=child_counts) return [parent], False selected: list[Any] = [] - pool = list(candidates) # shallow copy so we can remove picked items + pool = list(candidates) for _ in range(min(n_parents, len(pool))): parent = self.select_parent(pool, child_counts=child_counts) @@ -249,7 +277,6 @@ def select_parents( break selected.append(parent) pool = [c for c in pool if c is not parent] - # Safety: if we ended up with < 2, fall back to mutation if len(selected) < 2: return ( selected or [self.select_parent(candidates, child_counts=child_counts)], @@ -358,7 +385,9 @@ def _validate_open_ended( ) -> dict[str, Any]: """Novelty / QD validation: admit to archive if the candidate is improving or behaviourally novel. - QD weighting uses ``reward_uncapped`` (base + info_bonus − cheat). + QD weighting uses ``reward_uncapped`` (base + info_bonus − cheat), + then subtracts a length-penalty term so runaway code growth costs + ranking points without overriding it on real improvements. Args: baseline_list: Previous runs whose mean reward forms the bar. @@ -367,10 +396,9 @@ def _validate_open_ended( Returns: Validation result dict from :meth:`_build_result`, extended with - ``novelty_score``, ``qd_score``, ``archive_size``, - ``admit_rejected`` and ``admit_rejected_total``. + ``novelty_score``, ``qd_score``, ``length_penalty``, + ``archive_size``, ``admit_rejected`` and ``admit_rejected_total``. """ - # Reset the per-call eviction slot before _try_admit may set it. self._last_evicted_uuid = None baseline_reward = _mean_reward(baseline_list) @@ -379,11 +407,16 @@ def _validate_open_ended( new_reward_uncapped = _safe_attr(best_new, "reward_uncapped", 0.0) or new_reward descriptor = self._extract_behaviour_descriptor(best_new) + genotype_chars = _genotype_chars(best_new) novelty = self._compute_novelty(descriptor) - quality_norm = min(new_reward_uncapped, 1.0) - novelty_norm = min(novelty / max(self._novelty_range(), 1e-6), 1.0) - qd_score = (1 - self.novelty_weight) * quality_norm + self.novelty_weight * novelty_norm + novelty_range = self._novelty_range() + quality_norm = min(max(new_reward_uncapped, 0.0), 1.0) + novelty_norm = min(novelty / max(novelty_range, 1e-6), 1.0) + length_penalty = _length_penalty(genotype_chars, self.length_baseline_chars) + qd_score = self._compose_qd_score( + new_reward_uncapped, novelty, genotype_chars, novelty_range, + ) absolute_improvement = new_reward - baseline_reward relative_improvement = absolute_improvement / max(abs(baseline_reward), 1e-6) @@ -394,12 +427,14 @@ def _validate_open_ended( reward=new_reward, cost=_safe_attr(best_new, "cost", 0.0), uuid=_safe_attr(best_new, "current_uuid", None), - behaviour_descriptor=descriptor, + behaviour_descriptor=descriptor or [], + genotype_chars=genotype_chars, novelty_score=novelty, qd_score=qd_score, reward_uncapped=new_reward_uncapped, ) admit_rejected = not self._try_admit(member, is_valid) + self._record_previous(descriptor) confidence = min(1.0, qd_score / max(self.admit_threshold, 1e-6)) @@ -411,7 +446,8 @@ def _validate_open_ended( result["qd_score"] = qd_score result["quality_norm"] = quality_norm result["novelty_norm"] = novelty_norm - result["behaviour_descriptor"] = descriptor + result["length_penalty"] = length_penalty + result["behaviour_descriptor"] = descriptor or [] result["archive_size"] = len(self._archive) result["admit_rejected"] = admit_rejected result["admit_rejected_total"] = self._n_admit_rejected @@ -421,62 +457,92 @@ def _validate_open_ended( if self.strategy in (SelectionStrategy.NOVELTY, SelectionStrategy.QUALITY_DIVERSITY): self.logger.info( f"Open-ended: novelty={novelty:.3f}, qd={qd_score:.3f}, " + f"len_pen={length_penalty:.3f}, " f"archive={len(self._archive)}/{self.population_size}" ) return result - def _extract_behaviour_descriptor(self, run: Any) -> list[float]: - """Failure-fingerprint descriptor read from the verifier's output. - - Per-source pass rates are centered to a quality-free profile by - :func:`failure_fingerprint.compute_failure_fingerprint` and persisted - under ``state_result['evaluation']['verifier']['failure_fingerprint']``. - This method projects that vector out so QD novelty measures *how* - candidates fail relative to each other, not whether they fail. + def _extract_behaviour_descriptor(self, run: Any) -> list[float] | None: + """Unit-norm genotype embedding used as the QD behaviour descriptor. - When the run has no usable fingerprint (verifier not yet run, - short-circuit on a fully failed workflow) the neutral zero vector - is returned so distance lookups stay well-defined. + Reads a pre-computed ``run.behaviour_descriptor`` first (used by + tests injecting stub vectors); otherwise embeds ``run.code`` via + the configured genotype embedder. Args: - run: Object exposing a ``state_result`` attribute populated by - the evolution loop after evaluation. + run: Object exposing ``code`` (workflow source) and optionally + a pre-computed ``behaviour_descriptor``. Returns: - Centered fingerprint of length ``DESCRIPTOR_DIM``. + Unit-norm descriptor as ``list[float]``, or ``None`` when no + usable genotype was available — callers must treat the missing + signal as neutral, never as max-novel. """ - state_result = _safe_attr(run, "state_result", None) - fp = failure_fingerprint_from_state_result(state_result) - return fp if fp is not None else neutral_fingerprint() + precomputed = _safe_attr(run, "behaviour_descriptor", None) + if isinstance(precomputed, list) and precomputed: + return [float(x) for x in precomputed] + code = _safe_attr(run, "code", None) + return genotype_embedding_descriptor(code) - def _compute_novelty(self, descriptor: list[float]) -> float: - """Compute novelty as mean distance to k-nearest archive members. + def _compute_novelty(self, descriptor: list[float] | None) -> float: + """Mean cosine distance to the active comparison set. + + ``archive_knn`` (default) uses the mean distance to the k-nearest + archive members — the existing behaviour, generalised to cosine + distance. ``previous_n`` uses the mean distance to the N most + recently produced genotypes (lighter-weight mode that ignores + eviction). Args: - descriptor: Behaviour descriptor of the candidate. + descriptor: Candidate descriptor; ``None`` / empty yields ``0.0`` + so a missing embedding contributes nothing to QD score. Returns: - Mean Euclidean distance to the k nearest archive members; ``1.0`` - when the archive is empty (first individual is maximally novel). + Non-negative novelty value in ``[0, 2]`` (cosine distance range + for unit vectors). ``0.0`` for empty comparison sets so the + cold start is neither novel nor stale. """ - if not self._archive: - return 1.0 # First individual is maximally novel - - distances = [ - _euclidean(descriptor, m.behaviour_descriptor) - for m in self._archive - ] - distances.sort() + if not descriptor: + return 0.0 + peers = self._comparison_peers() + if not peers: + return 0.0 + distances = sorted(_cosine_distance(descriptor, p) for p in peers) + if self.novelty_comparison == NOVELTY_PREVIOUS_N: + return sum(distances) / len(distances) k = min(self.novelty_k, len(distances)) return sum(distances[:k]) / k if k > 0 else 0.0 + def _comparison_peers(self) -> list[list[float]]: + """Descriptors of the active comparison set, filtered to non-empty.""" + if self.novelty_comparison == NOVELTY_PREVIOUS_N: + return [p for p in self._previous_descriptors if p] + return [m.behaviour_descriptor for m in self._archive if m.behaviour_descriptor] + + def _record_previous(self, descriptor: list[float] | None) -> None: + """Push ``descriptor`` onto the previous-N sliding window.""" + if not descriptor: + return + self._previous_descriptors.append(list(descriptor)) + if len(self._previous_descriptors) > self.previous_n: + self._previous_descriptors.pop(0) + def _novelty_range(self) -> float: - """Estimate the typical novelty scale from the archive. + """Estimate the typical novelty scale from the active comparison set. Returns: - Maximum positive ``novelty_score`` across the archive, or ``1.0`` - when the archive has fewer than two members. + Maximum positive novelty seen across the current comparison + members, or ``1.0`` when too small to estimate. """ + if self.novelty_comparison == NOVELTY_PREVIOUS_N: + peers = self._comparison_peers() + if len(peers) < 2: + return 1.0 + pair_max = max( + _cosine_distance(peers[i], peers[j]) + for i in range(len(peers)) for j in range(i + 1, len(peers)) + ) + return pair_max if pair_max > 0 else 1.0 if len(self._archive) < 2: return 1.0 novelties = [m.novelty_score for m in self._archive if m.novelty_score > 0] @@ -525,31 +591,46 @@ def _add_to_archive(self, member: PopulationMember) -> None: def _refresh_member_metrics(self) -> None: """Recompute stored novelty + qd_score for every archive member.""" - n = len(self._archive) - if n < 2: + if len(self._archive) < 2: return - # Pass 1: k-NN novelty against current archive peers. for m in self._archive: - distances = sorted( - _euclidean(m.behaviour_descriptor, o.behaviour_descriptor) - for o in self._archive - if o is not m - ) - k = min(self.novelty_k, len(distances)) - m.novelty_score = sum(distances[:k]) / k if k > 0 else 0.0 - # Pass 2: renormalise qd_score against the current novelty range. + m.novelty_score = self._knn_novelty_against_peers(m) novelty_range = max( (m.novelty_score for m in self._archive if m.novelty_score > 0), default=1.0, ) for m in self._archive: - quality_norm = min(max(m.reward_uncapped, 0.0), 1.0) - novelty_norm = min(m.novelty_score / max(novelty_range, 1e-6), 1.0) - m.qd_score = ( - (1 - self.novelty_weight) * quality_norm - + self.novelty_weight * novelty_norm + m.qd_score = self._compose_qd_score( + m.reward_uncapped, m.novelty_score, m.genotype_chars, novelty_range, ) + def _knn_novelty_against_peers(self, m: PopulationMember) -> float: + """k-NN cosine-distance novelty for ``m`` against the rest of the archive.""" + distances = sorted( + _cosine_distance(m.behaviour_descriptor, o.behaviour_descriptor) + for o in self._archive + if o is not m and o.behaviour_descriptor + ) + k = min(self.novelty_k, len(distances)) + return sum(distances[:k]) / k if k > 0 else 0.0 + + def _compose_qd_score( + self, + reward_uncapped: float, + novelty: float, + genotype_chars: int, + novelty_range: float, + ) -> float: + """``(1-w)·quality + w·novelty − λ·length_penalty`` in one place.""" + quality_norm = min(max(reward_uncapped, 0.0), 1.0) + novelty_norm = min(novelty / max(novelty_range, 1e-6), 1.0) + length_penalty = _length_penalty(genotype_chars, self.length_baseline_chars) + return ( + (1 - self.novelty_weight) * quality_norm + + self.novelty_weight * novelty_norm + - self.length_lambda * length_penalty + ) + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -667,61 +748,92 @@ def _best_reward(runs: list[Any]) -> float: return max(rewards) if rewards else 0.0 -def _euclidean(a: list[float], b: list[float]) -> float: - """Euclidean distance between two vectors of equal length. +def _cosine_distance(a: list[float], b: list[float]) -> float: + """Cosine distance between two vectors of equal length. Args: a: First vector. b: Second vector. Returns: - Euclidean distance, or ``float("inf")`` when the vectors differ in - length (used as a sentinel for incomparable descriptors). + ``1 − cosine_similarity`` in ``[0, 2]``; ``1.0`` (neutral) when the + vectors are empty or have mismatching shapes. + """ + if not a or not b or len(a) != len(b): + return 1.0 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(x * x for x in b)) + if norm_a == 0.0 or norm_b == 0.0: + return 1.0 + cos = dot / (norm_a * norm_b) + return 1.0 - max(min(cos, 1.0), -1.0) + + +def _length_penalty(chars: int, baseline_chars: int) -> float: + """Clipped relative excess over the baseline genotype length. + + Args: + chars: Genotype character count for the run. + baseline_chars: Configured baseline above which the penalty grows. + + Returns: + ``clip((chars − baseline) / baseline, 0, 1)``. """ - if len(a) != len(b): - return float("inf") - return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) + if baseline_chars <= 0: + return 0.0 + raw = (chars - baseline_chars) / baseline_chars + return max(0.0, min(1.0, raw)) + + +def _genotype_chars(run: Any) -> int: + """Character length of the run's workflow source code, ``0`` when missing.""" + code = _safe_attr(run, "code", None) + return len(code) if isinstance(code, str) else 0 if __name__ == "__main__": from types import SimpleNamespace - def _state(vector: list[float]) -> dict[str, Any]: - """Build a state_result stub carrying a centered failure fingerprint.""" - return { - "evaluation": { - "verifier": { - "failure_fingerprint": { - "vector": vector, - "presence_mask": [1.0] * _DESCRIPTOR_DIM, - "pass_rates": [0.5] * _DESCRIPTOR_DIM, - } - } - } - } + def _run(reward: float, descriptor: list[float], uuid: str, *, code_len: int = 100) -> SimpleNamespace: + return SimpleNamespace( + reward=reward, reward_uncapped=reward, current_uuid=uuid, + iteration_count=1, cost=0.0, + code="x" * code_len, + behaviour_descriptor=descriptor, + ) sp = SelectionPressure( - strategy="qd", population_size=50, novelty_k_neighbours=25, novelty_weight=0.4, + strategy="qd", population_size=50, novelty_k_neighbours=25, + novelty_weight=0.4, length_penalty_baseline_chars=5000, + length_penalty_lambda=0.05, ) - seed = SimpleNamespace( - reward=0.97, reward_uncapped=1.05, current_uuid="seed", - iteration_count=1, cost=0.0, - state_result=_state([0.3, -0.2, 0.0, 0.0, -0.1, 0.0]), - ) + seed = _run(0.97, [0.6, 0.0, 0.0, 0.8], "seed") sp._validate_open_ended([seed], [seed], threshold=0.01) assert len(sp._archive) == 1, sp._archive - # Different failure profile: D and E refuted instead of A — should land - # despite a lower reward, because its fingerprint is far from the seed's. - distinct = SimpleNamespace( - reward=0.91, reward_uncapped=0.91, current_uuid="distinct", - iteration_count=5, cost=0.0, - state_result=_state([-0.2, 0.1, 0.0, -0.4, 0.5, 0.0]), - ) + # Distinct genotype embedding → admitted despite lower reward. + distinct = _run(0.91, [0.0, 1.0, 0.0, 0.0], "distinct") sp._validate_open_ended([seed], [distinct], threshold=0.01) - assert len(sp._archive) == 2, ( - f"distinct sibling rejected; archive={[m.uuid for m in sp._archive]}" + assert len(sp._archive) == 2, [m.uuid for m in sp._archive] + + # Length penalty discourages an oversized genotype: a 10x-longer + # neutral candidate should land but with qd_score reduced. + bloated = _run(0.85, [0.7, 0.7, 0.0, 0.0], "bloated", code_len=60000) + sp._validate_open_ended([seed], [bloated], threshold=0.01) + bloated_member = next((m for m in sp._archive if m.uuid == "bloated"), None) + assert bloated_member is not None + assert _length_penalty(60000, 5000) == 1.0 + + # previous_n mode: cosine distance against the recent window. + sp_pn = SelectionPressure( + strategy="qd", population_size=50, novelty_weight=0.4, + novelty_comparison="previous_n", previous_n=4, ) + sp_pn._validate_open_ended([seed], [seed], threshold=0.01) + sp_pn._validate_open_ended([seed], [distinct], threshold=0.01) + assert len(sp_pn._previous_descriptors) == 2 + assert sp_pn._comparison_peers(), "previous_n peers should be populated" - print("smoke OK: distinct failure profile admitted alongside higher-reward seed") \ No newline at end of file + print("smoke OK: cosine novelty + length penalty + previous_n mode") diff --git a/tests/selection_test.py b/tests/selection_test.py index 54d5ae1..cf71fd9 100644 --- a/tests/selection_test.py +++ b/tests/selection_test.py @@ -1,43 +1,66 @@ -"""Tests for SelectionPressure archive admission.""" +"""Tests for SelectionPressure archive admission. +Descriptors are passed in pre-computed (``run.behaviour_descriptor``) so +the tests stay deterministic without instantiating a sentence-transformer +backend. The selection module honours that injection path for testing. +""" + +import importlib.util import sys from pathlib import Path from types import SimpleNamespace -sys.path.append(str(Path(__file__).parent.parent)) +_REPO_ROOT = Path(__file__).parent.parent +sys.path.append(str(_REPO_ROOT)) + +# Import selection (and its genotype-embedding/code-features deps) directly: +# sources.core.__init__ pulls in litellm/sentence_transformers, which aren't +# needed here and break offline test runs. +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + sys.modules[name] = mod + return mod + + +_load("sources.core.genotype_embedding", _REPO_ROOT / "sources" / "core" / "genotype_embedding.py") +_load("sources.core.code_features", _REPO_ROOT / "sources" / "core" / "code_features.py") +_selection = _load("sources.core.selection", _REPO_ROOT / "sources" / "core" / "selection.py") -from sources.core.failure_fingerprint import DESCRIPTOR_DIM -from sources.core.selection import PopulationMember, SelectionPressure +PopulationMember = _selection.PopulationMember +SelectionPressure = _selection.SelectionPressure +_cosine_distance = _selection._cosine_distance +_length_penalty = _selection._length_penalty +# Distinct unit vectors used as stub embeddings — sized to look like an +# embedding (>1 dim, deterministic) without loading a real model. +_DIM: int = 6 -def _fp_state(vector: list[float]) -> dict: - """Build a state_result stub exposing a centered failure fingerprint.""" - assert len(vector) == DESCRIPTOR_DIM - return { - "evaluation": { - "verifier": { - "failure_fingerprint": { - "vector": vector, - "presence_mask": [1.0] * DESCRIPTOR_DIM, - "pass_rates": [0.5] * DESCRIPTOR_DIM, - } - } - } - } + +def _unit(values: list[float]) -> list[float]: + """Return a unit-norm copy of ``values`` for stub descriptors.""" + norm = sum(v * v for v in values) ** 0.5 + if norm == 0.0: + return values + return [v / norm for v in values] def _run( - vector: list[float] | None = None, + descriptor: list[float] | None = None, reward: float = 0.9, uuid: str = "u", + code_len: int = 100, ) -> SimpleNamespace: + """Build a minimal IndividualRun-like stub with a pre-baked descriptor.""" return SimpleNamespace( reward=reward, reward_uncapped=reward, current_uuid=uuid, iteration_count=1, cost=0.0, - state_result=_fp_state(vector if vector is not None else [0.0] * DESCRIPTOR_DIM), + code="x" * code_len, + behaviour_descriptor=_unit(descriptor) if descriptor else [0.0] * _DIM, ) @@ -45,30 +68,30 @@ def test_first_run_admitted(): sp = SelectionPressure(strategy="qd", population_size=50) sp._validate_open_ended( [_run(reward=0.0)], - [_run(reward=0.97, uuid="seed", vector=[0.3, -0.2, 0.0, 0.0, -0.1, 0.0])], + [_run(reward=0.97, uuid="seed", descriptor=[0.3, -0.2, 0.0, 0.0, -0.1, 0.0])], threshold=0.01, ) assert [m.uuid for m in sp._archive] == ["seed"] -def test_distinct_failure_profile_sibling_is_admitted(): - """Regression case under the new fingerprint descriptor. +def test_distinct_genotype_embedding_sibling_is_admitted(): + """A lower-reward sibling whose code embedding is far from the seed lands. - Mirror of the ClinTox c83cfabb scenario, restated in fingerprint space: - a lower-reward sibling whose failure profile fails on different sources - must land — quality-only selection would Pareto-reject it. + Quality-only selection would Pareto-reject it; the cosine-distance + novelty signal compensates. """ sp = SelectionPressure(strategy="qd", population_size=50, novelty_k_neighbours=25) seed = PopulationMember( iteration=1, reward=0.97, cost=0.0, uuid="seed", reward_uncapped=1.05, - behaviour_descriptor=[0.3, -0.2, 0.0, 0.0, -0.1, 0.0], + behaviour_descriptor=_unit([0.3, -0.2, 0.0, 0.0, -0.1, 0.0]), + genotype_chars=100, ) sp._archive = [seed] distinct = _run( reward=0.91, uuid="distinct", - vector=[-0.2, 0.1, 0.0, -0.4, 0.5, 0.0], + descriptor=[-0.2, 0.1, 0.0, -0.4, 0.5, 0.0], ) sp._validate_open_ended([seed], [distinct], threshold=0.01) @@ -99,6 +122,54 @@ def test_capacity_eviction_keeps_highest_qd(): assert "u1" not in {m.uuid for m in sp._archive} +def test_length_penalty_reduces_qd_for_bloated_genotype(): + """A genotype far above baseline pays a penalty in qd_score.""" + sp = SelectionPressure( + strategy="qd", population_size=50, novelty_weight=0.4, + length_penalty_baseline_chars=1000, length_penalty_lambda=0.1, + ) + descriptor = [0.7, 0.7, 0.0, 0.0, 0.0, 0.0] + short = _run(reward=0.9, uuid="short", descriptor=descriptor, code_len=500) + long = _run(reward=0.9, uuid="long", descriptor=descriptor, code_len=10000) + short_result = sp._validate_open_ended([short], [short], threshold=0.01) + sp_long = SelectionPressure( + strategy="qd", population_size=50, novelty_weight=0.4, + length_penalty_baseline_chars=1000, length_penalty_lambda=0.1, + ) + long_result = sp_long._validate_open_ended([long], [long], threshold=0.01) + assert short_result["length_penalty"] == 0.0 + assert long_result["length_penalty"] == 1.0 + assert long_result["qd_score"] < short_result["qd_score"] + + +def test_previous_n_mode_uses_recent_window(): + """``previous_n`` populates the sliding buffer instead of touching the archive.""" + sp = SelectionPressure( + strategy="qd", population_size=50, novelty_weight=0.4, + novelty_comparison="previous_n", previous_n=3, + ) + a = _run(reward=0.5, uuid="a", descriptor=[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + b = _run(reward=0.5, uuid="b", descriptor=[0.0, 1.0, 0.0, 0.0, 0.0, 0.0]) + c = _run(reward=0.5, uuid="c", descriptor=[0.0, 0.0, 1.0, 0.0, 0.0, 0.0]) + d = _run(reward=0.5, uuid="d", descriptor=[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]) + for r in (a, b, c, d): + sp._validate_open_ended([r], [r], threshold=0.01) + # Buffer caps at previous_n=3; the oldest descriptor falls out. + assert len(sp._previous_descriptors) == 3 + + +def test_cosine_distance_helpers(): + """Sanity checks on the new vector helpers.""" + assert _cosine_distance([1.0, 0.0], [1.0, 0.0]) == 0.0 + assert _cosine_distance([1.0, 0.0], [0.0, 1.0]) == 1.0 + assert abs(_cosine_distance([1.0, 0.0], [-1.0, 0.0]) - 2.0) < 1e-9 + assert _cosine_distance([], [1.0]) == 1.0 + assert _length_penalty(500, 1000) == 0.0 + assert _length_penalty(1000, 1000) == 0.0 + assert _length_penalty(2000, 1000) == 1.0 + assert _length_penalty(1500, 1000) == 0.5 + + if __name__ == "__main__": for name, fn in list(globals().items()): if name.startswith("test_") and callable(fn): From e495a8fbed5f19fb79baab92e846cf30039d27ae Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 18:10:19 +0200 Subject: [PATCH 24/71] docs : simplify docstring --- sources/core/code_features.py | 7 --- sources/core/evaluators/base.py | 4 -- sources/core/evaluators/generic.py | 6 --- sources/core/evaluators/grounding.py | 4 -- sources/core/evaluators/scenario.py | 4 -- sources/core/evaluators/verifier.py | 13 +---- sources/core/evaluators/verifier_claims.py | 50 +------------------ sources/core/evaluators/verifier_workspace.py | 6 --- sources/core/failure_fingerprint.py | 19 ++----- 9 files changed, 6 insertions(+), 107 deletions(-) diff --git a/sources/core/code_features.py b/sources/core/code_features.py index 8e43061..c15a176 100644 --- a/sources/core/code_features.py +++ b/sources/core/code_features.py @@ -1,12 +1,5 @@ """Genotype-embedding behaviour descriptor for the QD archive. -The descriptor must be orthogonal to fitness so the archive can separate -"different ways of being good" from "different ways of being mediocre". -This module exposes the workflow genotype's unit-norm code embedding as -the QD behaviour descriptor — two workflows whose generated code is -semantically similar collapse to the same point, so QD keeps only -genotypes that explored a different approach. - Backend selection lives in :mod:`sources.core.genotype_embedding`; this file is only the QD-facing shim. """ diff --git a/sources/core/evaluators/base.py b/sources/core/evaluators/base.py index 7f03709..fd3bca3 100644 --- a/sources/core/evaluators/base.py +++ b/sources/core/evaluators/base.py @@ -17,10 +17,6 @@ def extract_json_payload(text: str) -> str: """First balanced JSON object/array in *text*, tolerant of fences and prose. - Lives at module level (not on ``BaseEvaluator``) so non-evaluator callers - can use it too. Used by ``BaseEvaluator._call_judge_for_json`` and by any - code path that parses an LLM JSON response wrapped in commentary. - Args: text: Arbitrary string possibly containing a JSON document, with or without ``` fences and with prose around it. diff --git a/sources/core/evaluators/generic.py b/sources/core/evaluators/generic.py index 2ad8351..05bb0f6 100644 --- a/sources/core/evaluators/generic.py +++ b/sources/core/evaluators/generic.py @@ -324,12 +324,6 @@ def _extract_single_score(self, evaluation_text: str, expected_category: str) -> def evaluate(self, uuid: str, agent_answers: str | None = None) -> None: """Perform generic evaluation of a workflow. - Each evaluation criterion is assessed independently with a fresh LLM - context to avoid inter-criteria bias. The answer_plausibility criterion - additionally uses scientific literature grounding from Perspicacite. - Results are written to ``evaluation.txt`` in the workflow folder and - the score dictionary is persisted via ``_save_results``. - Args: uuid: UUID of the workflow to evaluate. agent_answers: Optional answers from agents for evaluation diff --git a/sources/core/evaluators/grounding.py b/sources/core/evaluators/grounding.py index f303345..1ac57ca 100644 --- a/sources/core/evaluators/grounding.py +++ b/sources/core/evaluators/grounding.py @@ -4,10 +4,6 @@ def get_perspicacite_grounding(goal: str) -> str: """Query Perspicacite for citation-grounded expectations of how a science goal should be achieved. - Builds a detailed prompt asking for 15-35 concrete success indicators with - literature backing, then dispatches it to Perspicacite via - :func:`sources.utils.perspicacite_client.query_perspicacite`. - Args: goal: The science goal given to the agents. diff --git a/sources/core/evaluators/scenario.py b/sources/core/evaluators/scenario.py index 1791817..ab442df 100644 --- a/sources/core/evaluators/scenario.py +++ b/sources/core/evaluators/scenario.py @@ -46,10 +46,6 @@ def __init__(self, config: "Config", scenarios_dir: str = "datasets/scenarios") def evaluate(self, uuid: str, scenario_rubric: str) -> dict[str, Any]: """Evaluate a workflow against a scenario with scoring. - Dispatches to the rubric or legacy evaluation path depending on the - scenario's structure. If ``uuid`` is ``None``, returns a zero-score - placeholder result. - Args: uuid: UUID of the workflow to evaluate. scenario_rubric: ID of the scenario to evaluate against. diff --git a/sources/core/evaluators/verifier.py b/sources/core/evaluators/verifier.py index d1e647f..255b941 100644 --- a/sources/core/evaluators/verifier.py +++ b/sources/core/evaluators/verifier.py @@ -855,11 +855,6 @@ def _aggregate(self, per_claim: list[dict[str, Any]]) -> dict[str, Any]: smooth gradient: flipping an importance-10 deliverable claim moves the score ~5× more than flipping a low-importance hygiene claim. - The thoroughness bonus saturates on importance-weighted mass of - high-importance passes (importance ≥ ``_INFO_BONUS_MIN_IMPORTANCE``). - The hard-fail cap fires when any claim with importance - ≥ ``_HARD_FAIL_IMPORTANCE`` is refuted (not errored, not unsure). - Args: per_claim: List of per-claim scored dicts from ``_verify_claim``. @@ -1058,13 +1053,7 @@ def _build_report( cheat: Any, min_importance: int = 0, ) -> str: - """Render a plain-text report from per-claim results and aggregate scores. - - When ``min_importance > 0`` only claims at or above that bar are - rendered; the aggregate header still reflects the full run. Used by - the prompt-gradient builder for a noise-suppressed view, while the - on-disk ``evaluation.txt`` keeps the full report (``min_importance=0``). - """ + """Render a plain-text report from per-claim results and aggregate scores.""" lines: list[str] = list(self._format_report_header(scores, min_importance)) for c in per_claim: lines.extend(self._format_claim_entry(c, min_importance)) diff --git a/sources/core/evaluators/verifier_claims.py b/sources/core/evaluators/verifier_claims.py index 4fc2d64..e940906 100644 --- a/sources/core/evaluators/verifier_claims.py +++ b/sources/core/evaluators/verifier_claims.py @@ -109,17 +109,6 @@ def _extract_claims( ) -> list[dict[str, Any]]: """Extract atomic claims by polling six independent source prompts. - Source A asks an LLM for what the LITERATURE demands of a correct - solution; Source B asks what the USER explicitly required in the goal - text; Source C asks for mathematical sanity properties of the - produced artefacts; Source D asks for the non-negotiable - computational-reproducibility requirements; Source E asks the - statistical-fingerprint / non-triviality questions. - - After merging across sources, ``_declare_claim_importance`` runs a - single rater pass that drops near-duplicates and assigns each - surviving claim an integer importance (1-10) anchored on the goal. - Args: uuid: Workflow identifier (used for logging and judge calls). goal: Original workflow goal text. @@ -557,10 +546,6 @@ def _parse_and_filter_claims( ) -> list[dict[str, Any]]: """Validate the LLM JSON, normalise each claim, drop confabulated paths. - Importance is intentionally NOT assigned here — it is rated by a - separate post-merge pass (``_declare_claim_importance``) so the - per-source extractors only need to enumerate candidates. - Args: uuid: Workflow identifier (used in error context). data: Parsed JSON payload from the judge; expected to contain a @@ -612,21 +597,6 @@ def _declare_claim_importance( ) -> list[dict[str, Any]]: """Drop near-duplicates and rate every surviving claim 1-10 vs the goal. - Runs as two phases so the slow part is parallelisable: - - * **Phase A — dedup (1 call):** the judge sees the full merged list of - ``(id, short description)`` pairs and returns only the ids of - near-duplicates to drop. Output is tiny so this call is cheap. - * **Phase B — rating (parallel batches):** surviving claims are sliced - into batches of ``_IMPORTANCE_BATCH_SIZE`` and each batch is rated - independently on ``_IMPORTANCE_PARALLELISM`` threads. Each entry - carries a *terse* rationale (≤8 words) instead of a full sentence, - which is what made the old single-call version slow. - - On phase failure the affected claims fall back to - ``importance = _DEFAULT_CLAIM_IMPORTANCE`` with an empty rationale, so - the run still scores rather than crashing. - Args: uuid: Workflow identifier (used for the judge call). goal: Workflow goal text — primary anchor for importance. @@ -676,13 +646,7 @@ def _run_dedup_pass( claims: list[dict[str, Any]], grounding: str, ) -> set[str]: - """Single cheap LLM call returning only ids to drop as near-duplicates. - - Output is just a list of ids, so the call generates almost no tokens - and finishes in seconds even on slow judges. On any error the dedup is - skipped (empty set) — duplicates will then bias the aggregate slightly - but the run still completes. - """ + """Single cheap LLM call returning only ids to drop as near-duplicates.""" prompt = self._build_dedup_prompt(goal, claims, grounding) data, err = self._call_judge_for_json( uuid, "verifier_dedup_claims", prompt @@ -702,13 +666,7 @@ def _rate_importance_parallel( claims: list[dict[str, Any]], grounding: str, ) -> dict[str, tuple[int, str]]: - """Split rating into batches and fan out across threads. - - Each batch only needs to see its own claims (dedup already happened in - phase A), so batches are independent and threads suffice — the LLM - client is sync HTTP. Failures in one batch fall back to default - importance for those claims; other batches keep their real ratings. - """ + """Split rating into batches and fan out across threads.""" batches = [ claims[i : i + _IMPORTANCE_BATCH_SIZE] for i in range(0, len(claims), _IMPORTANCE_BATCH_SIZE) @@ -844,10 +802,6 @@ def _extract_importance_map( ) -> dict[str, tuple[int, str]]: """Project the ``importance`` array into ``{id: (importance, rationale)}``. - Importance values are clamped to ``[1, 10]`` and rationales coerced to - strings; malformed entries are skipped silently — the caller falls back - to the default importance for any claim missing from the map. - Args: data: Parsed rater JSON. diff --git a/sources/core/evaluators/verifier_workspace.py b/sources/core/evaluators/verifier_workspace.py index a31ac21..f1540c2 100644 --- a/sources/core/evaluators/verifier_workspace.py +++ b/sources/core/evaluators/verifier_workspace.py @@ -155,12 +155,6 @@ def _validate_workspace_paths( (hallucination guard). When ``max_count`` is given, truncates to that cap. Returns paths in input order. - Lives on the workspace mixin because the validation is against - ``self._workspace_files`` — both the claim extractor (validating LLM - paths) and the per-claim file selector (validating verifier-input - paths) call this; centralising here avoids the claims mixin owning a - helper whose primary state lives elsewhere. - Args: raw: Candidate iterable of path strings from a model response. allowed: Optional whitelist of workspace-relative paths. diff --git a/sources/core/failure_fingerprint.py b/sources/core/failure_fingerprint.py index de1a2cb..d43eea2 100644 --- a/sources/core/failure_fingerprint.py +++ b/sources/core/failure_fingerprint.py @@ -1,19 +1,6 @@ -"""Failure-fingerprint behaviour descriptor for the QD archive. - -Replaces the topology-based descriptor for novelty: instead of measuring -"different DAG shapes", we measure "different failure profiles" — two -workflows that fail in the same way are redundant regardless of how -different their structure looks; two that fail in different ways explore -different basins and should both survive. - -Per source A-F, the descriptor uses the per-claim pass rate. Sources with -zero claims get a neutral value of ``0.5`` and presence-mask ``0``. The -final vector is then **centered**: the mean pass rate across present -sources is subtracted from every entry. This encodes the *profile shape* -of which sources fail relative to the others — NOT the overall quality -level. An all-pass run and an all-fail run both yield the zero profile; -quality already drives ``quality_norm`` in QD, and must not leak into -novelty or QD collapses back into greedy search. +""" +Failure-fingerprint behaviour descriptor for the QD archive. +[DEPRECATED] This descriptor is no longer used in the archive as diversity metrics """ import json From ee4f248c9a927d4eff17ff006712f83ac657d319 Mon Sep 17 00:00:00 2001 From: martin legrand Date: Mon, 15 Jun 2026 19:16:46 +0200 Subject: [PATCH 25/71] fix(verifier): don't pass error to report so it don't propagate to gradient report --- sources/core/evaluators/verifier.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sources/core/evaluators/verifier.py b/sources/core/evaluators/verifier.py index 255b941..416ba0c 100644 --- a/sources/core/evaluators/verifier.py +++ b/sources/core/evaluators/verifier.py @@ -1056,6 +1056,8 @@ def _build_report( """Render a plain-text report from per-claim results and aggregate scores.""" lines: list[str] = list(self._format_report_header(scores, min_importance)) for c in per_claim: + if c["status"] == "error": + continue # this avoid execution error of verifier passed to textual gradient lines.extend(self._format_claim_entry(c, min_importance)) lines.extend(self._format_cheat_section(cheat)) return "\n".join(lines) + "\n" From af0ca20d3b5d63f4f5eea64a1d84261a93068aac Mon Sep 17 00:00:00 2001 From: martin legrand Date: Wed, 17 Jun 2026 14:51:54 +0200 Subject: [PATCH 26/71] refactor: config comment --- config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config.py b/config.py index 3fb1fad..296de58 100644 --- a/config.py +++ b/config.py @@ -72,9 +72,7 @@ def __init__(self): self.max_learning_evolve_iterations = 20 # QD novelty + length penalty (open-ended modes) - # novelty_comparison: "archive_knn" (default) compares against the - # k-NN archive; "previous_n" compares only against the last N - # produced genotypes (lighter-weight, no archive memory). + # "archive_knn" (default) or "k-NN archive" self.novelty_comparison: str = "archive_knn" self.novelty_previous_n: int = 5 # Length penalty: genotype size at which the penalty starts to From e1a0bc5d86d99c3a06dd2ab60a39c2606e1c694b Mon Sep 17 00:00:00 2001 From: martin legrand Date: Wed, 17 Jun 2026 16:40:30 +0200 Subject: [PATCH 27/71] docs: sync multi-agent evolution claims with code --- README.md | 8 +- docs/DEVELOPER_GUIDE.md | 52 +- docs/concepts/architecture.md | 13 +- docs/concepts/evolution-engine.md | 86 +- docs/getting-started/configuration.md | 8 +- docs/getting-started/quickstart.md | 4 +- docs/reference/configuration.md | 6 +- docs/site/DEVELOPER_GUIDE/index.html | 332 +- docs/site/concepts/architecture/index.html | 14 +- .../concepts/evaluation-pipeline/index.html | 54 + .../site/concepts/evolution-engine/index.html | 236 +- docs/site/diagrams/evolution_loop.mermaid | 14 +- .../getting-started/configuration/index.html | 8 +- .../getting-started/quickstart/index.html | 4 +- docs/site/images/evolution_tree.png | Bin 0 -> 218095 bytes .../info-flow/failure_fingerprint/index.html | 2303 ++++++ docs/site/reference/cli/index.html | 19 +- docs/site/reference/configuration/index.html | 8 +- docs/site/reference/file-layout/index.html | 251 +- docs/site/search/search_index.json | 2 +- docs/site/site/404.html | 1951 +++++ docs/site/site/DEVELOPER_GUIDE/index.html | 3442 +++++++++ docs/site/site/about/citation/index.html | 2226 ++++++ docs/site/site/assets/images/favicon.png | Bin 0 -> 1870 bytes .../assets/javascripts/bundle.79ae519e.min.js | 16 + .../javascripts/bundle.79ae519e.min.js.map | 7 + .../javascripts/lunr/min/lunr.ar.min.js | 1 + .../javascripts/lunr/min/lunr.da.min.js | 18 + .../javascripts/lunr/min/lunr.de.min.js | 18 + .../javascripts/lunr/min/lunr.du.min.js | 18 + .../javascripts/lunr/min/lunr.el.min.js | 1 + .../javascripts/lunr/min/lunr.es.min.js | 18 + .../javascripts/lunr/min/lunr.fi.min.js | 18 + .../javascripts/lunr/min/lunr.fr.min.js | 18 + .../javascripts/lunr/min/lunr.he.min.js | 1 + .../javascripts/lunr/min/lunr.hi.min.js | 1 + .../javascripts/lunr/min/lunr.hu.min.js | 18 + .../javascripts/lunr/min/lunr.hy.min.js | 1 + .../javascripts/lunr/min/lunr.it.min.js | 18 + .../javascripts/lunr/min/lunr.ja.min.js | 1 + .../javascripts/lunr/min/lunr.jp.min.js | 1 + .../javascripts/lunr/min/lunr.kn.min.js | 1 + .../javascripts/lunr/min/lunr.ko.min.js | 1 + .../javascripts/lunr/min/lunr.multi.min.js | 1 + .../javascripts/lunr/min/lunr.nl.min.js | 18 + .../javascripts/lunr/min/lunr.no.min.js | 18 + .../javascripts/lunr/min/lunr.pt.min.js | 18 + .../javascripts/lunr/min/lunr.ro.min.js | 18 + .../javascripts/lunr/min/lunr.ru.min.js | 18 + .../javascripts/lunr/min/lunr.sa.min.js | 1 + .../lunr/min/lunr.stemmer.support.min.js | 1 + .../javascripts/lunr/min/lunr.sv.min.js | 18 + .../javascripts/lunr/min/lunr.ta.min.js | 1 + .../javascripts/lunr/min/lunr.te.min.js | 1 + .../javascripts/lunr/min/lunr.th.min.js | 1 + .../javascripts/lunr/min/lunr.tr.min.js | 18 + .../javascripts/lunr/min/lunr.vi.min.js | 1 + .../javascripts/lunr/min/lunr.zh.min.js | 1 + .../site/assets/javascripts/lunr/tinyseg.js | 206 + .../site/assets/javascripts/lunr/wordcut.js | 6708 +++++++++++++++++ .../workers/search.2c215733.min.js | 42 + .../workers/search.2c215733.min.js.map | 7 + .../assets/stylesheets/main.484c7ddc.min.css | 1 + .../stylesheets/main.484c7ddc.min.css.map | 1 + .../stylesheets/palette.ab4e12ef.min.css | 1 + .../stylesheets/palette.ab4e12ef.min.css.map | 1 + docs/site/site/cla-process/index.html | 2245 ++++++ .../site/concepts/architecture/index.html | 2426 ++++++ .../concepts/evaluation-pipeline/index.html | 2576 +++++++ .../site/concepts/evolution-engine/index.html | 2448 ++++++ docs/site/site/concepts/grounding/index.html | 2309 ++++++ docs/site/site/concepts/index.html | 2114 ++++++ docs/site/site/concepts/toolomics/index.html | 2396 ++++++ .../site/concepts/tools-and-mcp/index.html | 2359 ++++++ .../site/developer/contributing/index.html | 2366 ++++++ docs/site/site/developer/extending/index.html | 2452 ++++++ .../diagrams/architecture_overall.mermaid | 85 + docs/site/site/diagrams/class_diagram.mermaid | 65 + .../site/site/diagrams/evolution_loop.mermaid | 98 + .../illustrate_instruments_use.mermaid | 26 + .../illustrate_papers_reproduciton.mermaid | 18 + .../site/diagrams/verifiers_judge.mermaid | 83 + .../evaluation/custom-benchmarks/index.html | 2372 ++++++ docs/site/site/evaluation/index.html | 2253 ++++++ .../getting-started/configuration/index.html | 2472 ++++++ docs/site/site/getting-started/index.html | 2103 ++++++ .../getting-started/installation/index.html | 2338 ++++++ .../getting-started/quickstart/index.html | 2347 ++++++ .../site/site/images/architecture_overall.png | Bin 0 -> 228505 bytes docs/site/site/images/evaluation_pipeline.png | Bin 0 -> 194732 bytes docs/site/site/images/evolution_loop.png | Bin 0 -> 233331 bytes docs/site/site/images/evolve_example.png | Bin 0 -> 153600 bytes docs/site/site/images/logo_mimosa.png | Bin 0 -> 233493 bytes docs/site/site/images/mimosa_illustration.png | Bin 0 -> 4117097 bytes docs/site/site/images/mimosa_overall.jpg | Bin 0 -> 1215712 bytes docs/site/site/images/network.png | Bin 0 -> 42463 bytes docs/site/site/images/workflow_mutation.png | Bin 0 -> 103873 bytes docs/site/site/index.html | 2596 +++++++ docs/site/site/licensing-notes/index.html | 2206 ++++++ docs/site/site/paper.tex | 1051 +++ .../site/papers_bench_evaluation/index.html | 2534 +++++++ docs/site/site/reference/cli/index.html | 2457 ++++++ .../site/reference/configuration/index.html | 2665 +++++++ .../site/reference/file-layout/index.html | 2365 ++++++ .../site/reference/troubleshooting/index.html | 2792 +++++++ .../science_agent_bench_evaluation/index.html | 2779 +++++++ docs/site/site/search/search_index.json | 1 + docs/site/site/sitemap.xml | 139 + docs/site/site/sitemap.xml.gz | Bin 0 -> 522 bytes docs/site/site/stylesheets/extra.css | 69 + docs/site/site/usage/index.html | 2102 ++++++ docs/site/site/usage/learning/index.html | 2385 ++++++ docs/site/site/usage/modes/index.html | 2497 ++++++ docs/site/site/usage/notifications/index.html | 2271 ++++++ docs/site/site/usage/telemetry/index.html | 2318 ++++++ docs/site/site/usage/transparency/index.html | 2419 ++++++ docs/site/site/usage/workspace/index.html | 2413 ++++++ docs/site/sitemap.xml | 72 +- docs/site/sitemap.xml.gz | Bin 522 -> 544 bytes docs/site/usage/learning/index.html | 47 +- docs/site/usage/transparency/index.html | 192 +- docs/usage/learning.md | 43 +- 122 files changed, 94174 insertions(+), 508 deletions(-) create mode 100644 docs/site/images/evolution_tree.png create mode 100644 docs/site/info-flow/failure_fingerprint/index.html create mode 100644 docs/site/site/404.html create mode 100644 docs/site/site/DEVELOPER_GUIDE/index.html create mode 100644 docs/site/site/about/citation/index.html create mode 100644 docs/site/site/assets/images/favicon.png create mode 100644 docs/site/site/assets/javascripts/bundle.79ae519e.min.js create mode 100644 docs/site/site/assets/javascripts/bundle.79ae519e.min.js.map create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.ar.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.da.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.de.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.du.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.el.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.es.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.fi.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.fr.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.he.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.hi.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.hu.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.hy.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.it.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.ja.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.jp.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.kn.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.ko.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.multi.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.nl.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.no.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.pt.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.ro.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.ru.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.sa.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.sv.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.ta.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.te.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.th.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.tr.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.vi.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/min/lunr.zh.min.js create mode 100644 docs/site/site/assets/javascripts/lunr/tinyseg.js create mode 100644 docs/site/site/assets/javascripts/lunr/wordcut.js create mode 100644 docs/site/site/assets/javascripts/workers/search.2c215733.min.js create mode 100644 docs/site/site/assets/javascripts/workers/search.2c215733.min.js.map create mode 100644 docs/site/site/assets/stylesheets/main.484c7ddc.min.css create mode 100644 docs/site/site/assets/stylesheets/main.484c7ddc.min.css.map create mode 100644 docs/site/site/assets/stylesheets/palette.ab4e12ef.min.css create mode 100644 docs/site/site/assets/stylesheets/palette.ab4e12ef.min.css.map create mode 100644 docs/site/site/cla-process/index.html create mode 100644 docs/site/site/concepts/architecture/index.html create mode 100644 docs/site/site/concepts/evaluation-pipeline/index.html create mode 100644 docs/site/site/concepts/evolution-engine/index.html create mode 100644 docs/site/site/concepts/grounding/index.html create mode 100644 docs/site/site/concepts/index.html create mode 100644 docs/site/site/concepts/toolomics/index.html create mode 100644 docs/site/site/concepts/tools-and-mcp/index.html create mode 100644 docs/site/site/developer/contributing/index.html create mode 100644 docs/site/site/developer/extending/index.html create mode 100644 docs/site/site/diagrams/architecture_overall.mermaid create mode 100644 docs/site/site/diagrams/class_diagram.mermaid create mode 100644 docs/site/site/diagrams/evolution_loop.mermaid create mode 100644 docs/site/site/diagrams/illustrate_instruments_use.mermaid create mode 100644 docs/site/site/diagrams/illustrate_papers_reproduciton.mermaid create mode 100644 docs/site/site/diagrams/verifiers_judge.mermaid create mode 100644 docs/site/site/evaluation/custom-benchmarks/index.html create mode 100644 docs/site/site/evaluation/index.html create mode 100644 docs/site/site/getting-started/configuration/index.html create mode 100644 docs/site/site/getting-started/index.html create mode 100644 docs/site/site/getting-started/installation/index.html create mode 100644 docs/site/site/getting-started/quickstart/index.html create mode 100644 docs/site/site/images/architecture_overall.png create mode 100644 docs/site/site/images/evaluation_pipeline.png create mode 100644 docs/site/site/images/evolution_loop.png create mode 100644 docs/site/site/images/evolve_example.png create mode 100644 docs/site/site/images/logo_mimosa.png create mode 100644 docs/site/site/images/mimosa_illustration.png create mode 100644 docs/site/site/images/mimosa_overall.jpg create mode 100644 docs/site/site/images/network.png create mode 100644 docs/site/site/images/workflow_mutation.png create mode 100644 docs/site/site/index.html create mode 100644 docs/site/site/licensing-notes/index.html create mode 100644 docs/site/site/paper.tex create mode 100644 docs/site/site/papers_bench_evaluation/index.html create mode 100644 docs/site/site/reference/cli/index.html create mode 100644 docs/site/site/reference/configuration/index.html create mode 100644 docs/site/site/reference/file-layout/index.html create mode 100644 docs/site/site/reference/troubleshooting/index.html create mode 100644 docs/site/site/science_agent_bench_evaluation/index.html create mode 100644 docs/site/site/search/search_index.json create mode 100644 docs/site/site/sitemap.xml create mode 100644 docs/site/site/sitemap.xml.gz create mode 100644 docs/site/site/stylesheets/extra.css create mode 100644 docs/site/site/usage/index.html create mode 100644 docs/site/site/usage/learning/index.html create mode 100644 docs/site/site/usage/modes/index.html create mode 100644 docs/site/site/usage/notifications/index.html create mode 100644 docs/site/site/usage/telemetry/index.html create mode 100644 docs/site/site/usage/transparency/index.html create mode 100644 docs/site/site/usage/workspace/index.html diff --git a/README.md b/README.md index 534a56a..db3efb5 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,10 @@ Five layers, wired through small dataclass schemas — full details in [`docs/co Workflows are **full Python programs**, mutated as source code. The **code-as-genotype** is the workflow file; the phenotype is whatever it produces in the workspace. -- **Selection: Quality-Diversity archive** (**MAP-Elites**-style) — max population of 50, `qd_score = (1−w)·quality + w·novelty` (`w=0.4`). **Novelty search** uses k-NN distance (`k=25`) over a **behaviour descriptor** `[n_agents, n_edges, n_branches, prompt_chars]`. Parents drawn by inverse-child-count roulette so the archive spreads. -- **Variation: stagnation-driven scope** — mutation boldness is a continuous function of how much the last 4 prompt gradients repeat themselves. Near-winners stay protected. Scope bands run from "prompt-only tweak" to "complete topology rethink." -- **Crossover** — ~30 % of generations combine two parents, strongest-first. -- **Cold start** — when the archive is empty, a similarity-filtered scan of past runs on disk (MiniLM cosine ≥ 0.5) seeds the search. Useful workflows transfer across tasks. +- **Selection: Quality-Diversity archive** (**MAP-Elites**-style) — max population of 50, `qd_score = (1−w)·quality + w·novelty` (`w = novelty_weight = 0.25`). **Novelty search** uses k-NN distance (`k = 15`) over the **failure-fingerprint** behaviour descriptor — a 6-D vector of centered per-source pass rates from the verifier (sources A–F). Parents drawn by inverse-child-count roulette so the archive spreads (`MAX_CHILDREN_PER_PARENT = 2`). +- **Variation: Rechenberg-1/5 + plateau-driven scope** — mutation boldness blends the success rate of the last 5 scored offspring (Rechenberg 1/5 rule, threshold `0.20`) with an `iters_since_improvement` plateau counter (patience `6`). Near-winners (parent score > 0.95) get a damper. Scope bands run from `EXPLOITATION` (point mutation) to `RE-SPECIATION` (clean-slate redesign), gated by an effective-boldness threshold (`< 0.35 / 0.50 / 0.65 / 0.90 / 1.01`). +- **Crossover** — by default ~10 % of generations combine two parents, strongest-first, with offspring hard-capped at the highest parent agent count. +- **Cold start** — when the archive is empty, a similarity-filtered scan of past runs on disk (MiniLM cosine ≥ 0.8) seeds the search. Useful workflows transfer across tasks. Full mechanics: [`docs/concepts/evolution-engine.md`](./docs/concepts/evolution-engine.md). diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 7d2cf29..efd88b6 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -256,9 +256,9 @@ Each recursive step: 6. selects the next parent(s) and chooses mutation vs crossover, 7. recurses. -Termination: `overall_score > learned_score_threshold` (default 0.94) in +Termination: `overall_score >= learned_score_threshold` (default 0.9) in `--learn` mode, or `max_depth` reached -(`max_learning_evolve_iterations`, default 45; single-shot uses +(`max_learning_evolve_iterations`, default 20; single-shot uses `max_depth=1`). ### 2. `SelectionPressure` — [`sources/core/selection.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/selection.py) @@ -278,39 +278,45 @@ penalty `÷(1 + n_children_already)` and a hard ### 3. `VariationEngine` — [`sources/core/variation_engine.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/variation_engine.py) Prompt assembly for mutation and crossover. Mutation boldness is a -continuous function of two evidence signals — population stagnation -*and* the Rechenberg 1/5 success rate of recent offspring — not a fixed -phase schedule. - -- `_compute_stagnation(window=4)` — mean pairwise MiniLM cosine - similarity over the last 4 non-failure prompt gradients, rescaled so - the unrelated baseline (`≈0.4`) maps to `0` and full repetition - (`≥0.8`) maps to `1`. +continuous function of two evidence signals — an +`iters_since_improvement` plateau counter *and* the Rechenberg 1/5 +success rate of recent offspring — not a fixed phase schedule. + +- `_iters_since_improvement()` — length of the trailing run of scored + offspring that did not strictly beat best-so-far (failures and + unscored entries skipped). Normalised as + `plateau = min(1, iters / _PLATEAU_PATIENCE)` with + `_PLATEAU_PATIENCE = 6`. - `_compute_success_rate(window=5)` — fraction of the last 5 scored offspring that strictly beat the running best at production time. The Rechenberg 1/5 success rule threshold is `0.20`. - `_get_prompt_step_size(parent_score)` — combines the two: - * cold start (no scored history yet) — `effective = raw_stagnation`, - * `success_rate < 0.20` — `effective = max(raw_stagnation, deficit)` + * cold start (fewer than two comparable scored offspring) — + `effective = 0.3 · plateau` (capped ramp), + * `success_rate < 0.20` — `effective = 0.5 · deficit + 0.5 · plateau` with `deficit = (0.20 − success_rate) / 0.20` (escalate), - * `success_rate ≥ 0.20` — `effective = raw_stagnation · (1 − progress)` + * `success_rate ≥ 0.20` — `effective = plateau · (1 − progress)` with `progress = min(1, (success_rate − 0.20) / (0.80 − 0.20))` (damp boldness in proportion to real progress), * near-finish floor: when `parent_score > 0.95`, multiply by `(1 − 0.5 · (parent_score − 0.95) / 0.05)` so a 0.96 parent isn't - gambled away one generation before early-stop. + gambled away one generation before early-stop, + * RE-SPECIATION gate: unless `iters_since_improvement ≥ 8` *and* + `success_rate ∈ {None, 0.0}`, `effective` is clamped to + `_RESPECIATION_CLAMP = 0.89`, just below the EXPLORATION/ + RE-SPECIATION boundary at `0.90`. Then it grows the agent budget from the previous generation's count toward `max_possible_agents = 7` proportionally to `effective`, and samples the actual agent count with a Beta-Binomial biased upward by `effective`. -| Effective boldness | Agent budget | Mutation scope (advisory) | -|--------------------|--------------|-------------------------------------------------------------| -| <0.20 | ≈ current | prompt-only little tweak | -| <0.40 | current+1 | prompt, handoff, tools — improve information flow | -| <0.60 | current+2 | significant redesign while keeping topology | -| <0.80 | current+3 | bold rewire — restructure or grow the agent set | -| ≥0.80 | up to 7 | complete rethink — discard inherited topology / prompts | +| Effective boldness | Mutation scope (advisory) | +|--------------------|-----------------------------------------------------------------------------| +| < 0.35 | `EXPLOITATION` — point mutation: minor phrasing / prompt-adjective tweaks | +| < 0.50 | `ALIGNMENT` — interface optimization: refine handoff prompts, IO contracts | +| < 0.65 | `ADAPTATION` — component overhaul: rewrite lagging agent prompts, swap tools | +| < 0.90 | `EXPLORATION` — macro structural mutation: add/merge agents, change routing | +| ≥ 0.90 | `RE-SPECIATION` — clean-slate redesign of the multi-agent architecture | Scope is an advisory line injected into the mutation prompt; the LLM may still pick any topology. The hard control is the agent-count @@ -323,7 +329,7 @@ Two-mode parent retrieval: - **Steady state**: when `selection_pressure._archive` is populated, draws parents from the live session archive via QD-roulette. - **Cold start**: empty archive → similarity-filtered disk scan - (`cosine ≥ 0.5` on MiniLM embeddings of `original_task`, `score ≥ 0.05`) + (`cosine ≥ 0.8` on MiniLM embeddings of `original_task`, `score ≥ 0.1`) routed through the same `select_parents()` weighting. ### 5. `WorkflowOrchestrator` — [`sources/core/orchestrator.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/orchestrator.py) @@ -401,7 +407,7 @@ EvolutionEngine.start_workflow_evolution(goal) ├─ SelectionPressure.validate_survivor() → archive admit? ├─ record_lineage() ├─ select next parent (archive QD-roulette) - ├─ choose crossover (~0.4 rate, once initial_population met) or mutation + ├─ choose crossover (default crossover_rate=0.1, once initial_population met) or mutation └─ recurse → stop on threshold OR max_depth ↓ WorkspaceManager.restore_best(best_uuid) diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 5d22cb2..6564065 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -45,12 +45,13 @@ recursively evolves workflows, with help from: (improvement over baseline or `qd_score > admit_threshold`); capacity is curated by lowest-`qd_score` eviction. - **VariationEngine** — assembles mutation or crossover prompts, with an - evidence-driven mutation scope: boldness grows as recent prompt - gradients converge (the offspring keep failing the same way) *and* as - the Rechenberg 1/5 success rate of recent scored offspring drops - below 20 %. A near-finish floor only damps boldness once the parent - score is above 0.95, so near-winners aren't gambled away one - generation before early-stop. + evidence-driven mutation scope: boldness grows with an + `iters_since_improvement` plateau counter (patience `6`) *and* as the + Rechenberg 1/5 success rate of the last 5 scored offspring drops below + 20 %. A near-finish floor only damps boldness once the parent score is + above 0.95, so near-winners aren't gambled away one generation before + early-stop. The top `RE-SPECIATION` band is hysteresis-gated and only + opens after `iters_since_improvement ≥ 8` with a zero success rate. - **WorkflowOrchestrator** — wraps "grounding → factory → sandbox" into one callable per generation. diff --git a/docs/concepts/evolution-engine.md b/docs/concepts/evolution-engine.md index b66379d..3b0feb3 100644 --- a/docs/concepts/evolution-engine.md +++ b/docs/concepts/evolution-engine.md @@ -17,7 +17,7 @@ flowchart TB Seed -- yes --> SeedPrompt[Seed genome prompt
or template mutation] Seed -- no --> Pick[Pick parents via QD-roulette
fallback to disk similarity scan] SeedPrompt --> Orch[Orchestrate workflow
LLM → sandbox] - Pick --> Decide{Crossover ≈ 0.4?} + Pick --> Decide{Crossover ≈ 0.1?} Decide -- mutation --> Mut[Mutation prompt
stagnation-scoped] Decide -- crossover --> Cross[Crossover prompt
best-parent-first] Mut --> Orch @@ -52,9 +52,9 @@ A more detailed view lives in the source diagram Termination: -- `overall_score > learned_score_threshold` (default `0.94`) in `--learn` mode, *or* +- `overall_score >= learned_score_threshold` (default `0.9`) in `--learn` mode, *or* - `max_depth` reached — `1` in single-shot mode, `max_learning_evolve_iterations` - (default `45`) in `--learn` mode. + (default `20`) in `--learn` mode. ## Selection: Quality-Diversity (QD) @@ -64,12 +64,12 @@ implements four strategies — `greedy`, `tournament`, `novelty`, and `qd` - A **session archive** holds up to `population_size = 50` members. - Each member has `qd_score = (1−w)·quality_norm + w·novelty_norm`, with - `w = novelty_weight = 0.4`. Quality and novelty are **additive** — never + `w = novelty_weight = 0.25`. Quality and novelty are **additive** — never multiplied — so high quality cannot rescue a redundant profile and high novelty cannot drag a broken run above peers. - Quality is sourced from `reward_uncapped` so the hard-fail cap doesn't flatten rank ordering. -- Novelty is k-NN distance (`k = 25`) in **failure-fingerprint** space. +- Novelty is k-NN distance (`k = 15`) in **failure-fingerprint** space. The descriptor is the centered per-source pass-rate vector produced by [`failure_fingerprint.py`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/failure_fingerprint.py) from the verifier's per-claim verdicts (see below). @@ -83,8 +83,8 @@ implements four strategies — `greedy`, `tournament`, `novelty`, and `qd` offspring stream stays spread across the archive. When the archive is empty (cold start), [`WorkflowSelector`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/workflow_selection.py) -falls back to a **similarity-filtered disk scan** (`cosine ≥ 0.5` on MiniLM -embeddings of `original_task`, `score ≥ 0.05`) — this lets useful workflows +falls back to a **similarity-filtered disk scan** (`cosine ≥ 0.8` on MiniLM +embeddings of `original_task`, `score ≥ 0.1`) — this lets useful workflows transfer across tasks. ### Behaviour descriptor: failure fingerprint @@ -128,14 +128,15 @@ for QD novelty — empirical work showed it barely co-varies with outcomes. [`VariationEngine`](https://github.com/HolobiomicsLab/Mimosa-AI/blob/main/sources/core/variation_engine.py) assembles mutation and crossover prompts. There is no fixed phase schedule by iteration progress; mutation boldness is a continuous -function of two evidence signals — how much the population is -repeating itself, *and* how often recent offspring have actually -improved on the best-so-far. +function of two evidence signals — how long the lineage has been +failing to improve, *and* how often recent offspring actually beat the +best-so-far. -**Stagnation signal.** `_compute_stagnation(window=4)` takes the last -4 non-failure prompt gradients, computes their pairwise MiniLM cosine -similarity, and rescales the mean (`0.4` ≈ unrelated → `0`, -`0.8+` ≈ fully stagnated → `1`). +**Plateau signal.** `_iters_since_improvement()` counts the run of +trailing scored offspring (failures and unscored entries skipped) that +did not strictly beat the best-so-far at the moment they were +produced. It is normalised to `plateau = min(1, iters / _PLATEAU_PATIENCE)` +with `_PLATEAU_PATIENCE = 6`. **Success-rate signal.** `_compute_success_rate(window=5)` counts the fraction of the last 5 scored offspring whose `overall_score` strictly @@ -146,42 +147,49 @@ is happening and step size should be damped. **Effective boldness.** Combining the two signals: -- **Cold start** (no scored offspring yet) — `effective = raw_stagnation`. -- **Below 1/5** (`success_rate < 0.20`) — escalate at least to the - deficit: `effective = max(raw_stagnation, deficit)`, where - `deficit = (0.20 − success_rate) / 0.20`. A repeated gradient and a - flat reward curve both force scope up. +- **Cold start** (fewer than two comparable scored offspring) — + `effective = 0.3 · plateau`. A capped cold-start ramp avoids jumping + straight into RE-SPECIATION before any feedback has accumulated. +- **Below 1/5** (`success_rate < 0.20`) — average the deficit and the + plateau: `effective = 0.5 · deficit + 0.5 · plateau`, where + `deficit = (0.20 − success_rate) / 0.20`. A run of no-improvements + and a stalling success rate both push scope up. - **Above 1/5** — damp boldness in proportion to how far above - threshold we are: `effective = raw_stagnation · (1 − progress)`, - where `progress = min(1, (success_rate − 0.20) / (0.80 − 0.20))`. - At `success_rate ≥ 0.80` boldness collapses regardless of stagnation. + threshold we are: `effective = plateau · (1 − progress)`, where + `progress = min(1, (success_rate − 0.20) / (0.80 − 0.20))`. At + `success_rate ≥ 0.80` boldness collapses regardless of plateau. - **Near-finish floor** — only in the last 5 % of the score range, `effective` is multiplied by `(1 − 0.5 · near_finish)` where - `near_finish = (parent_score − 0.95) / 0.05`. This is the *only* - point where the parent's absolute score re-enters the boldness + `near_finish = max(0, (parent_score − 0.95) / 0.05)`. This is the + *only* point where the parent's absolute score re-enters the boldness calculation, so a 0.96 parent isn't gambled away one generation before early-stop. +- **RE-SPECIATION gate.** The top band is hysteresis-gated: unless + `iters_since_improvement ≥ _RESPECIATION_PATIENCE` (default `8`) *and* + `success_rate ∈ {None, 0.0}`, `effective` is clamped to + `_RESPECIATION_CLAMP = 0.89` — just below the EXPLORATION/RE-SPECIATION + boundary at `0.90`. Notably, `parent_score` no longer multiplies the whole signal — that older behaviour locked high-score lineages into "tiny tweak" mode even -when the gradient kept repeating identically. +when offspring kept failing identically. **Agent budget.** The current agent count grows toward `max_possible_agents = 7` proportionally to `effective`, then a Beta-Binomial draw samples the actual count inside that window (biased upward by `effective`). The seed generation samples agents -from `[1, 4]` with a `0.5` stagnation prior. +from `[1, 4]` with a `0.5` boldness prior. **Scope band.** A single advisory line is added to the mutation prompt, chosen by `effective`: -| Effective boldness | Mutation scope | -| ------------------- | ---------------------------------------------------------------------- | -| < 0.20 | prompt-only little tweak | -| < 0.40 | prompt, handoff, tools — improve information flow | -| < 0.60 | significant redesign while keeping topology | -| < 0.80 | bold rewire — restructure or grow the agent set | -| ≥ 0.80 | complete rethink — discard inherited topology / prompts | +| Effective boldness | Mutation scope | +| ------------------- | --------------------------------------------------------------------------- | +| < 0.35 | `EXPLOITATION` — point mutation: minor phrasing / prompt-adjective tweaks | +| < 0.50 | `ALIGNMENT` — interface optimization: refine handoff prompts, IO contracts | +| < 0.65 | `ADAPTATION` — component overhaul: rewrite lagging agent prompts, swap tools | +| < 0.90 | `EXPLORATION` — macro structural mutation: add/merge agents, change routing | +| ≥ 0.90 | `RE-SPECIATION` — clean-slate redesign of the multi-agent architecture | The bands are advisory text steered to the LLM, not hard gates: the LLM can still pick any topology. The hard control is the agent-count @@ -189,9 +197,9 @@ budget passed in the same prompt block. ## Crossover -With probability ~0.4 per generation (and only once at least -`initial_population = 2` runs have happened), two parents are combined -instead of one being mutated. The crossover prompt is +With probability `crossover_rate` per generation (default `0.1`, and only +once at least `initial_population = 2` runs have happened), two parents +are combined instead of one being mutated. The crossover prompt is **best-parent-first**: the strongest parent's code structure leads, weaker parents contribute specific improvements rather than competing for the skeleton, and the offspring is hard-capped at the highest @@ -225,8 +233,10 @@ Each iteration also writes structured metrics for post-hoc analysis: `iteration_wall_time_s`, `iteration_cost_usd`, `cumulative_cost_usd`, `overall_score{,_uncapped}`, `qd_descriptor`, `qd_score`, `novelty_score`, the `selection_log`, and the - `variation_state` (`stagnation`, `success_rate`, `effective_boldness`, - `scope_band`, `agent_budget`) that produced this offspring. + `variation_state` (`iters_since_improvement`, `plateau`, + `success_rate`, `effective_boldness`, `parent_score`, + `respeciation_gate_open`, `scope_band`, `agent_budget`) that produced + this offspring. - `sources/workflows/qd_archive.jsonl` — append-only, one line per `validate_survivor` call. Records the candidate's descriptor, `qd_score`, `novelty_score`, admission verdict, and the `evicted_uuid` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 39baf98..29ee811 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -22,8 +22,8 @@ field with defaults and types, see the [Configuration reference](../reference/co | `smolagent_model_id` | LLM used by execution agents inside each workflow. | | `judge_model` | LLM that scores soft claims in the verifier. | | `planner_llm_model` | LLM that decomposes goals into tasks (`--goal` mode only). | -| `learned_score_threshold` | Score that triggers early stop in `--learn` mode (default `0.97`). | -| `max_learning_evolve_iterations` | Hard cap on evolve iterations (default `35`). | +| `learned_score_threshold` | Score that triggers early stop in `--learn` mode (default `0.9`). | +| `max_learning_evolve_iterations` | Hard cap on evolve iterations (default `20`). | ## Choosing models @@ -95,8 +95,8 @@ uv run main.py --task "…" \ | Field | Default | Meaning | | ----- | ------- | ------- | -| `learned_score_threshold` | `0.97` | Stop evolving when `overall_score` reaches this. | -| `max_learning_evolve_iterations` | `35` | Max generations before giving up. | +| `learned_score_threshold` | `0.9` | Stop evolving when `overall_score` reaches this. | +| `max_learning_evolve_iterations` | `20` | Max generations before giving up. | See [Iterative learning](../usage/learning.md) for the full evolution machinery. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 98fe957..8a1f712 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -62,8 +62,8 @@ uv run main.py \ ``` In learning mode, Mimosa evolves up to `max_learning_evolve_iterations` -generations or stops as soon as `overall_score > learned_score_threshold` -(default `0.95`). See [Iterative learning](../usage/learning.md). +generations or stops as soon as `overall_score >= learned_score_threshold` +(default `0.9`). See [Iterative learning](../usage/learning.md). ## 4. Try goal mode diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ce7ca7c..391642f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -34,15 +34,15 @@ Ports must be in `[0, 65535]` and `port_min ≤ port_max`. | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | | `prompt_planner` | `str` | `sources/prompts/planner_reproduction.md` | Planner system prompt. | -| `prompt_workflow_creator` | `str` | `sources/prompts/workflow_v10.md` | Workflow-generation prompt. | +| `prompt_workflow_creator` | `str` | `sources/prompts/workflow_v11.md` | Workflow-generation prompt. | | `prompt_smolagent` | `str` | `sources/prompts/smolagent_sys_prompt.md` | SmolAgent system prompt. | ## Learning | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | -| `learned_score_threshold` | `float` | `0.97` | `--learn` stops when `overall_score` exceeds this. | -| `max_learning_evolve_iterations` | `int` | `35` | Hard cap on evolve iterations. | +| `learned_score_threshold` | `float` | `0.9` | `--learn` stops when `overall_score` reaches this. | +| `max_learning_evolve_iterations` | `int` | `20` | Hard cap on evolve iterations. | | `max_concurrent_eval_tasks` | `int` | `1` | Concurrent tasks in CSV / batch modes. | ## OpenRouter routing diff --git a/docs/site/DEVELOPER_GUIDE/index.html b/docs/site/DEVELOPER_GUIDE/index.html index 5e3ebbc..86b24ee 100644 --- a/docs/site/DEVELOPER_GUIDE/index.html +++ b/docs/site/DEVELOPER_GUIDE/index.html @@ -2836,127 +2836,129 @@

Directory Structure│ │ ├── selection.py # SelectionPressure (greedy/tournament/novelty/QD) │ │ ├── variation_engine.py # Mutation/crossover prompt assembly + annealing │ │ ├── workflow_selection.py # Parent retrieval (archive draw / disk scan) -│ │ ├── code_features.py # AST → behaviour descriptor (4-vector) -│ │ ├── lineage.py # parent → child sidecar records -│ │ ├── orchestrator.py # Grounding → factory → sandbox pipeline -│ │ ├── workflow_factory.py # Multi-agent workflow synthesis -│ │ ├── single_agent_factory.py # Single-agent baseline factory -│ │ ├── factory.py # Shared factory primitives -│ │ ├── workflow_runner.py # Sandboxed Python execution -│ │ ├── workflow_info.py # Workflow metadata reader -│ │ ├── tools_manager.py # MCP tool discovery -│ │ ├── llm_provider.py # Multi-provider LLM abstraction -│ │ ├── planner.py # Goal → Task decomposition (Layer 0) -│ │ ├── schema.py # IndividualRun, Plan, Task, SelectionLog -│ │ └── evaluators/ -│ │ ├── evaluator.py # WorkflowEvaluator facade (routes to backends) -│ │ ├── verifier.py # Multi-source per-claim verifier (default) -│ │ ├── grounding.py # Perspicacite literature-grounding adapter -│ │ ├── generic.py # Legacy LLM judge (4-criterion) -│ │ ├── scenario.py # Rubric-based evaluation -│ │ ├── bs_detection.py # BullshitDetector penalty -│ │ └── base.py # Shared evaluator primitives -│ │ -│ ├── evaluation/ -│ │ ├── csv_mode.py # Concurrent batch eval on CSV datasets -│ │ ├── capsule_evaluator.py # ScienceAgentBench metrics (VER/SR/CBS) -│ │ ├── codebert_scorer.py # Semantic code similarity -│ │ ├── execution_sandbox.py # Safe code execution for benchmark eval -│ │ ├── scenario_loader.py # Load scenario rubrics -│ │ ├── science_agent_bench.py # ScienceAgentBench dataset integration -│ │ └── eval_workflow_generation.py # Workflow-generation-quality eval -│ │ -│ ├── cli/ -│ │ ├── onboard_cli.py # Interactive zero-arg onboarding flow -│ │ ├── evaluation_cli.py # Interactive ScienceAgentBench launcher -│ │ └── pretty_print.py # Coloured CLI primitives (print_phase, …) -│ │ -│ ├── extensibility/ -│ │ ├── human_mode.py # Manual no-LLM CLI mode -│ │ └── text_to_speech.py # TTS hook -│ │ -│ ├── modules/ # Pre-fab code injected into workflows -│ │ ├── state_schema.py # Workflow state template -│ │ └── smolagent_factory.py # SmolAgent factory template -│ │ -│ ├── prompts/ -│ │ ├── workflow_v10.md # Current workflow generator prompt -│ │ ├── workflow_v9.md # (kept for diffing) -│ │ ├── workflow_v8.md # (legacy reference) -│ │ ├── planner_reproduction.md # Planner prompt — reproduction goal -│ │ ├── planner_paperbench_codedev.md # Planner prompt — code-dev paperbench -│ │ └── smolagent_sys_prompt.md # SmolAgent system prompt -│ │ -│ ├── cache/ -│ │ └── openrouter_pricing.json # Cached pricing for cost tracking -│ │ -│ ├── security/ -│ │ └── check_package.py # Pre-flight package vetting -│ │ -│ ├── utils/ -│ │ ├── pricing.py # LLM pricing (OpenRouter-aware) -│ │ ├── logging.py # Structured logging -│ │ ├── notify.py # Pushover notifications -│ │ ├── transfer_toolomics.py # Workspace ↔ Toolomics transfer -│ │ ├── workspace_management.py # Snapshot / restore best run -│ │ ├── perspicacite_client.py # Literature grounding HTTP client -│ │ ├── planner_visualization.py # Real-time plan visualisation -│ │ ├── evolution_tree.py # Lineage → tree PNG renderer -│ │ ├── visualization.py # Reward/assertion plots -│ │ ├── shared_visualization.py # Shared plot primitives -│ │ ├── email_reporter.py # Email run summaries -│ │ ├── openrouter_endpoints.py # OpenRouter endpoint catalogue -│ │ ├── precheck.py # Environment validation -│ │ ├── list_files.py # Workspace listing helper -│ │ ├── dataset.py # CSV / scenario helpers -│ │ └── mock_data.py # Test fixtures -│ │ -│ ├── memory/ # LLM call cache + memory traces (runtime) -│ └── workflows/ # Generated workflow storage (runtime) -│ └── <uuid>/ # Per-execution folders -│ ├── workflow_genotype_<uuid>.py -│ ├── state_result.json -│ ├── evolution_prompt_<uuid>.md -│ ├── lineage_<uuid>.json -│ ├── reward_progress.png -│ └── memory/ -│ -├── runs_capsule/ -│ └── <capsule_name>/ # Per-execution capsule -│ ├── workflow.py -│ ├── results/ -│ ├── logs/ -│ └── evaluation_results.json -│ -├── datasets/ -│ ├── ScienceAgentBench.csv # ScienceAgentBench tasks -│ ├── ScienceAgentBench/ # Per-task workspaces -│ ├── our_benchmark.csv # Custom benchmark -│ ├── paper_bench.csv # OpenAI PaperBench -│ ├── paper_bench_light.csv # Light variant -│ ├── papers_rejection_watch.csv # Rejected-paper tracking -│ ├── datascience_papers.csv # DS papers list -│ └── scenarios/ # Scenario rubrics -│ -├── docs/ -│ ├── DEVELOPER_GUIDE.md # This file -│ ├── QUICK_RESEARCH_GUIDE.md # Quick research workflow -│ ├── v2_evolution.md # Neuroevolution-lens view of the engine -│ ├── math_lens.md # Math-style analysis of representation/QD -│ ├── papers_bench_evaluation.md -│ ├── science_agent_bench_evaluation.md -│ ├── diagrams/ # .mermaid sources + .puml -│ └── images/ # Rendered .png diagrams -│ -└── tests/ - ├── evaluator_test.py - ├── scenario_rubric_test.py - ├── judge_test.py - ├── workflow_evaluator_test.py - ├── tools_manager_test.py - ├── pricing_test.py - ├── memory_read.py - └── cosine_similarity.py +│ │ ├── failure_fingerprint.py # Verifier verdicts → QD behaviour descriptor (6-D, centered) +│ │ ├── code_features.py # Legacy structural descriptor (offline analysis only) +│ │ ├── lineage.py # parent → child sidecar records +│ │ ├── orchestrator.py # Grounding → factory → sandbox pipeline +│ │ ├── workflow_factory.py # Multi-agent workflow synthesis +│ │ ├── single_agent_factory.py # Single-agent baseline factory +│ │ ├── factory.py # Shared factory primitives +│ │ ├── workflow_runner.py # Sandboxed Python execution +│ │ ├── workflow_info.py # Workflow metadata reader +│ │ ├── tools_manager.py # MCP tool discovery +│ │ ├── llm_provider.py # Multi-provider LLM abstraction +│ │ ├── planner.py # Goal → Task decomposition (Layer 0) +│ │ ├── schema.py # IndividualRun, Plan, Task, SelectionLog +│ │ └── evaluators/ +│ │ ├── evaluator.py # WorkflowEvaluator facade (routes to backends) +│ │ ├── verifier.py # Multi-source per-claim verifier (default) +│ │ ├── grounding.py # Perspicacite literature-grounding adapter +│ │ ├── generic.py # Legacy LLM judge (4-criterion) +│ │ ├── scenario.py # Rubric-based evaluation +│ │ ├── bs_detection.py # BullshitDetector penalty +│ │ └── base.py # Shared evaluator primitives +│ │ +│ ├── evaluation/ +│ │ ├── csv_mode.py # Concurrent batch eval on CSV datasets +│ │ ├── capsule_evaluator.py # ScienceAgentBench metrics (VER/SR/CBS) +│ │ ├── codebert_scorer.py # Semantic code similarity +│ │ ├── execution_sandbox.py # Safe code execution for benchmark eval +│ │ ├── scenario_loader.py # Load scenario rubrics +│ │ ├── science_agent_bench.py # ScienceAgentBench dataset integration +│ │ └── eval_workflow_generation.py # Workflow-generation-quality eval +│ │ +│ ├── cli/ +│ │ ├── onboard_cli.py # Interactive zero-arg onboarding flow +│ │ ├── evaluation_cli.py # Interactive ScienceAgentBench launcher +│ │ └── pretty_print.py # Coloured CLI primitives (print_phase, …) +│ │ +│ ├── extensibility/ +│ │ ├── human_mode.py # Manual no-LLM CLI mode +│ │ └── text_to_speech.py # TTS hook +│ │ +│ ├── modules/ # Pre-fab code injected into workflows +│ │ ├── state_schema.py # Workflow state template +│ │ └── smolagent_factory.py # SmolAgent factory template +│ │ +│ ├── prompts/ +│ │ ├── workflow_v11.md # Current workflow generator prompt +│ │ ├── workflow_v10.md # (kept for diffing) +│ │ ├── workflow_v9.md # (legacy reference) +│ │ ├── workflow_v8.md # (legacy reference) +│ │ ├── planner_reproduction.md # Planner prompt — reproduction goal +│ │ ├── planner_paperbench_codedev.md # Planner prompt — code-dev paperbench +│ │ └── smolagent_sys_prompt.md # SmolAgent system prompt +│ │ +│ ├── cache/ +│ │ └── openrouter_pricing.json # Cached pricing for cost tracking +│ │ +│ ├── security/ +│ │ └── check_package.py # Pre-flight package vetting +│ │ +│ ├── utils/ +│ │ ├── pricing.py # LLM pricing (OpenRouter-aware) +│ │ ├── logging.py # Structured logging +│ │ ├── notify.py # Pushover notifications +│ │ ├── transfer_toolomics.py # Workspace ↔ Toolomics transfer +│ │ ├── workspace_management.py # Snapshot / restore best run +│ │ ├── perspicacite_client.py # Literature grounding HTTP client +│ │ ├── planner_visualization.py # Real-time plan visualisation +│ │ ├── evolution_tree.py # Lineage → tree PNG renderer +│ │ ├── visualization.py # Reward/assertion plots +│ │ ├── shared_visualization.py # Shared plot primitives +│ │ ├── email_reporter.py # Email run summaries +│ │ ├── openrouter_endpoints.py # OpenRouter endpoint catalogue +│ │ ├── precheck.py # Environment validation +│ │ ├── list_files.py # Workspace listing helper +│ │ ├── dataset.py # CSV / scenario helpers +│ │ └── mock_data.py # Test fixtures +│ │ +│ ├── memory/ # LLM call cache + memory traces (runtime) +│ └── workflows/ # Generated workflow storage (runtime) +│ └── <uuid>/ # Per-execution folders +│ ├── workflow_genotype_<uuid>.py +│ ├── state_result.json +│ ├── evolution_prompt_<uuid>.md +│ ├── lineage_<uuid>.json +│ ├── reward_progress.png +│ └── memory/ +│ +├── runs_capsule/ +│ └── <capsule_name>/ # Per-execution capsule +│ ├── workflow.py +│ ├── results/ +│ ├── logs/ +│ └── evaluation_results.json +│ +├── datasets/ +│ ├── ScienceAgentBench.csv # ScienceAgentBench tasks +│ ├── ScienceAgentBench/ # Per-task workspaces +│ ├── our_benchmark.csv # Custom benchmark +│ ├── paper_bench.csv # OpenAI PaperBench +│ ├── paper_bench_light.csv # Light variant +│ ├── papers_rejection_watch.csv # Rejected-paper tracking +│ ├── datascience_papers.csv # DS papers list +│ └── scenarios/ # Scenario rubrics +│ +├── docs/ +│ ├── DEVELOPER_GUIDE.md # This file +│ ├── QUICK_RESEARCH_GUIDE.md # Quick research workflow +│ ├── v2_evolution.md # Neuroevolution-lens view of the engine +│ ├── math_lens.md # Math-style analysis of representation/QD +│ ├── papers_bench_evaluation.md +│ ├── science_agent_bench_evaluation.md +│ ├── diagrams/ # .mermaid sources + .puml +│ └── images/ # Rendered .png diagrams +│ +└── tests/ + ├── evaluator_test.py + ├── scenario_rubric_test.py + ├── judge_test.py + ├── workflow_evaluator_test.py + ├── tools_manager_test.py + ├── pricing_test.py + ├── memory_read.py + └── cosine_similarity.py

Core Components

@@ -2967,8 +2969,8 @@

1. EvolutionEngin - WorkflowOrchestrator — grounding → factory → sandbox. - VariationEngine — mutation / crossover prompt assembly. - WorkflowEvaluator — multi-source per-claim verifier (default). -- SelectionPressure — QD archive (population_size=50, k=25, - novelty_weight=0.4).

+- SelectionPressure — QD archive (population_size=50, k=15, + novelty_weight=0.25).

Each recursive step: 1. resets the workspace to the initial state, 2. orchestrates a workflow run (LLM → sandbox), @@ -2978,69 +2980,85 @@

1. EvolutionEngin on (reward_uncapped, novelty), 6. selects the next parent(s) and chooses mutation vs crossover, 7. recurses.

-

Termination: overall_score > learned_score_threshold (default 0.97) in +

Termination: overall_score >= learned_score_threshold (default 0.9) in --learn mode, or max_depth reached -(max_learning_evolve_iterations, default 35; single-shot uses +(max_learning_evolve_iterations, default 20; single-shot uses max_depth=1).

2. SelectionPressuresources/core/selection.py

Four strategies: greedy, tournament, novelty, qd (default). In QD mode it maintains a session archive of up to population_size members, weighted by qd_score = (1-w)·quality_norm + w·novelty_norm -(w = novelty_weight = 0.4). Quality is sourced from reward_uncapped +(w = novelty_weight = 0.25). Quality is sourced from reward_uncapped so the hard-fail cap (_HARD_FAIL_CAP, currently 0.99) doesn't flatten rank ordering. Admission is gated by the validity check (improvement over baseline or qd_score > admit_threshold); when capacity is hit, the lowest- qd_score member is evicted. Parent draw applies an inverse-child-count -penalty ÷(1 + n_children_already) to spread offspring.

+penalty ÷(1 + n_children_already) and a hard +MAX_CHILDREN_PER_PARENT = 2 cap to spread offspring.

3. VariationEnginesources/core/variation_engine.py

Prompt assembly for mutation and crossover. Mutation boldness is a -continuous function of population stagnation, not a fixed phase -schedule.

+continuous function of two evidence signals — an +iters_since_improvement plateau counter and the Rechenberg ⅕ +success rate of recent offspring — not a fixed phase schedule.

    -
  • _compute_stagnation(window=4) — mean pairwise MiniLM cosine - similarity over the last 4 non-failure prompt gradients, rescaled so - the unrelated baseline (≈0.4) maps to 0 and full repetition - (≥0.8) maps to 1.
  • -
  • _get_prompt_step_size(parent_score) — computes - stagnation_effective = raw_stagnation · (1 − parent_score), grows - the agent budget from the previous generation's count toward - max_possible_agents = 7 proportionally to it, then samples the - actual agent count with a Beta-Binomial biased upward by stagnation.
  • +
  • _iters_since_improvement() — length of the trailing run of scored + offspring that did not strictly beat best-so-far (failures and + unscored entries skipped). Normalised as + plateau = min(1, iters / _PLATEAU_PATIENCE) with + _PLATEAU_PATIENCE = 6.
  • +
  • _compute_success_rate(window=5) — fraction of the last 5 scored + offspring that strictly beat the running best at production time. + The Rechenberg ⅕ success rule threshold is 0.20.
  • +
  • _get_prompt_step_size(parent_score) — combines the two:
      +
    • cold start (fewer than two comparable scored offspring) — + effective = 0.3 · plateau (capped ramp),
    • +
    • success_rate < 0.20effective = 0.5 · deficit + 0.5 · plateau + with deficit = (0.20 − success_rate) / 0.20 (escalate),
    • +
    • success_rate ≥ 0.20effective = plateau · (1 − progress) + with progress = min(1, (success_rate − 0.20) / (0.80 − 0.20)) + (damp boldness in proportion to real progress),
    • +
    • near-finish floor: when parent_score > 0.95, multiply by + (1 − 0.5 · (parent_score − 0.95) / 0.05) so a 0.96 parent isn't + gambled away one generation before early-stop,
    • +
    • RE-SPECIATION gate: unless iters_since_improvement ≥ 8 and + success_rate ∈ {None, 0.0}, effective is clamped to + _RESPECIATION_CLAMP = 0.89, just below the EXPLORATION/ + RE-SPECIATION boundary at 0.90. + Then it grows the agent budget from the previous generation's count + toward max_possible_agents = 7 proportionally to effective, and + samples the actual agent count with a Beta-Binomial biased upward by + effective.
    • +
    +
- - + - - - + + - - - + + - - - + + - - - + + - - - + +
Stagnation effectiveAgent budgetEffective boldness Mutation scope (advisory)
<0.20≈ currentprompt-only little tweak< 0.35EXPLOITATION — point mutation: minor phrasing / prompt-adjective tweaks
<0.40current+1prompt, handoff, tools — improve information flow< 0.50ALIGNMENT — interface optimization: refine handoff prompts, IO contracts
<0.60current+2significant redesign while keeping topology< 0.65ADAPTATION — component overhaul: rewrite lagging agent prompts, swap tools
<0.80current+3bold rewire — restructure or grow the agent set< 0.90EXPLORATION — macro structural mutation: add/merge agents, change routing
≥0.80up to 7complete rethink — discard inherited topology / prompts≥ 0.90RE-SPECIATION — clean-slate redesign of the multi-agent architecture
@@ -3053,7 +3071,7 @@

4. WorkflowSel
  • Steady state: when selection_pressure._archive is populated, draws parents from the live session archive via QD-roulette.
  • Cold start: empty archive → similarity-filtered disk scan - (cosine ≥ 0.5 on MiniLM embeddings of original_task, score ≥ 0.05) + (cosine ≥ 0.8 on MiniLM embeddings of original_task, score ≥ 0.1) routed through the same select_parents() weighting.
  • 5. WorkflowOrchestratorsources/core/orchestrator.py

    @@ -3148,7 +3166,7 @@

    Mode 1: Task mode (--task) ├─ SelectionPressure.validate_survivor() → archive admit? ├─ record_lineage() ├─ select next parent (archive QD-roulette) - ├─ choose crossover (~0.3 rate) or mutation + ├─ choose crossover (default crossover_rate=0.1, once initial_population met) or mutation └─ recurse → stop on threshold OR max_depth WorkspaceManager.restore_best(best_uuid) diff --git a/docs/site/concepts/architecture/index.html b/docs/site/concepts/architecture/index.html index 2e2dd5f..85d253a 100644 --- a/docs/site/concepts/architecture/index.html +++ b/docs/site/concepts/architecture/index.html @@ -2225,13 +2225,17 @@

    Layer 2 — Meta-orchestration + + +
  • + + + + Failure fingerprint (QD behaviour descriptor) + + + +
  • @@ -2053,6 +2064,17 @@ +
  • + +
  • + + + + Failure fingerprint (QD behaviour descriptor) + + + +
  • @@ -2198,6 +2220,11 @@

    Evaluation pipelineevolution engine +uses as the behaviour descriptor for QD novelty. The descriptor is +centered so overall quality cannot leak into novelty — see the firewall +section below.

    Evaluation pipeline

    The pipeline

    The verifier runs four stages per workflow run:

    @@ -2325,6 +2352,33 @@

    AggregationThe engine separately keeps overall_score_uncapped (pre-cap) so QD rank ordering doesn't flatten under hard fails.

  • +

    Failure fingerprint (QD behaviour descriptor)

    +

    The verifier doesn't just emit a score — the same per-claim verdicts feed +the QD novelty signal as a failure fingerprint: a centered vector +of per-source pass rates that tells the archive how a candidate fails, +not whether it failed.

    +
    # Per source A..F (six entries, always — absent sources get a neutral value).
    +pass_rate[s] = passes[s] / total[s]              if total[s] > 0  else 0.5
    +presence[s]  = 1.0                                if total[s] > 0  else 0.0
    +# Center so the descriptor encodes profile shape, not quality level.
    +mean_present = mean(pass_rate[s] for s where presence[s] == 1)
    +vector[s]    = pass_rate[s] - mean_present       if presence[s] == 1
    +             = 0                                  otherwise
    +
    +

    The quality firewall. An all-pass run and an all-fail run both yield +the zero profile. This is intended and asserted in the tests +(test_all_pass_yields_zero_profile, +test_all_fail_yields_zero_profile). The QD score combines quality and +novelty additively(1 − w)·quality_norm + w·novelty_norm — so +quality already drives quality_norm. If quality also leaked into +novelty, QD would collapse back into greedy search. The centering step +is what keeps these two terms separable.

    +

    The fingerprint is persisted under +state_result.jsonevaluation.verifier.failure_fingerprint.vector +and consumed by +SelectionPressure._extract_behaviour_descriptor. +Full info-flow audit: +docs/info-flow/failure_fingerprint.md.

    Prompt gradient

    After aggregation the verifier composes a single-sentence diagnosis (the prompt gradient) from the per-claim report and recent history of diff --git a/docs/site/concepts/evolution-engine/index.html b/docs/site/concepts/evolution-engine/index.html index 28f9a38..c073cae 100644 --- a/docs/site/concepts/evolution-engine/index.html +++ b/docs/site/concepts/evolution-engine/index.html @@ -878,13 +878,30 @@ +

    +
  • - + - Variation: stagnation-driven mutation scope + Variation: evidence-driven mutation scope (Rechenberg ⅕ rule) @@ -922,6 +939,17 @@ +
  • + +
  • + + + + Run metrics artifacts + + + +
  • @@ -2031,13 +2059,30 @@ + +
  • - + - Variation: stagnation-driven mutation scope + Variation: evidence-driven mutation scope (Rechenberg ⅕ rule) @@ -2075,6 +2120,17 @@ +
  • + +
  • + + + + Run metrics artifacts + + + +
  • @@ -2170,7 +2226,7 @@

    Big pictureEach recursive stepSelection: Quality-Diversity (QD)

    SelectionPressure @@ -2213,69 +2269,138 @@

    Selection: Quality-Diversity (QD)
  • A session archive holds up to population_size = 50 members.
  • Each member has qd_score = (1−w)·quality_norm + w·novelty_norm, with - w = novelty_weight = 0.4.
  • + w = novelty_weight = 0.25. Quality and novelty are additive — never + multiplied — so high quality cannot rescue a redundant profile and high + novelty cannot drag a broken run above peers.

  • Quality is sourced from reward_uncapped so the hard-fail cap doesn't flatten rank ordering.
  • -
  • Novelty is k-NN distance (k = 25) in behaviour-descriptor space, where - the descriptor is [n_agents, n_edges, n_branches, prompt_chars] - extracted by code_features.py.
  • +
  • Novelty is k-NN distance (k = 15) in failure-fingerprint space. + The descriptor is the centered per-source pass-rate vector produced by + failure_fingerprint.py + from the verifier's per-claim verdicts (see below).
  • Admission gate: candidate is admitted when it either improves over baseline by min_improvement_threshold or clears qd_score > admit_threshold. When the archive reaches capacity, the lowest-qd_score member is evicted.
  • Parent draw applies an inverse-child-count penalty - ÷ (1 + n_children_already) to spread offspring across the archive.
  • + ÷ (1 + n_children_already), and parents are hard-capped at + MAX_CHILDREN_PER_PARENT = 2 before that penalty kicks in, so the + offspring stream stays spread across the archive.

    When the archive is empty (cold start), WorkflowSelector -falls back to a similarity-filtered disk scan (cosine ≥ 0.5 on MiniLM -embeddings of original_task, score ≥ 0.05) — this lets useful workflows +falls back to a similarity-filtered disk scan (cosine ≥ 0.8 on MiniLM +embeddings of original_task, score ≥ 0.1) — this lets useful workflows transfer across tasks.

    -

    Variation: stagnation-driven mutation scope

    +

    Behaviour descriptor: failure fingerprint

    +

    The novelty signal compares candidates in failure-fingerprint space. +Per source A–F (literature, user goal, agent narration, math invariants, +computational reproducibility, statistical fingerprint), the verifier +records a pass rate. Sources with zero claims get the neutral value +0.5 and a presence-mask entry of 0. The vector is then centered: +the mean pass rate across present sources is subtracted from every entry.

    +

    The centering is the quality firewall. Without it, an all-pass run sits +at [1,1,1,1,1,1] and an all-fail run at [0,0,0,0,0,0] — Euclidean +distance between them is large, and quality silently leaks into novelty. +After centering, both runs collapse to the zero profile and novelty +encodes only the shape of which sources fail relative to the others. +Two workflows that fail in the same way are redundant regardless of how +different their DAGs look; two that fail in different ways explore +different basins and both deserve a seat in the archive.

    +

    The fingerprint is computed at the end of VerifierEvaluator.evaluate() +and persisted in state_result.json under +evaluation.verifier.failure_fingerprint.vector. The full audit trail — +which variable comes from where, the failure modes the descriptor must +survive, and the centering invariant asserted by the tests — lives in +docs/info-flow/failure_fingerprint.md.

    +

    When a run has no usable fingerprint (verifier short-circuit on a fully +failed workflow), SelectionPressure._extract_behaviour_descriptor +returns a neutral zero vector so distance lookups stay well-defined and +the cold path doesn't artificially win or lose on novelty.

    +

    The legacy structural descriptor ([n_agents, n_edges, n_branches, +prompt_chars]) shipped by +code_features.py +is retained for ablations and offline analysis but is no longer used +for QD novelty — empirical work showed it barely co-varies with outcomes.

    +

    Variation: evidence-driven mutation scope (Rechenberg ⅕ rule)

    VariationEngine assembles mutation and crossover prompts. There is no fixed phase schedule by iteration progress; mutation boldness is a continuous -function of how much the population is repeating itself.

    -

    Stagnation signal. _compute_stagnation() takes the last 4 -non-failure prompt gradients, computes their pairwise MiniLM cosine -similarity, and rescales the mean (0.4 ≈ unrelated → 0, -0.8+ ≈ fully stagnated → 1).

    -

    Effective boldness. stagnation_effective = raw_stagnation · (1 − -parent_score). Near-winners stay protected from disruption even when -the population stagnates.

    +function of two evidence signals — how long the lineage has been +failing to improve, and how often recent offspring actually beat the +best-so-far.

    +

    Plateau signal. _iters_since_improvement() counts the run of +trailing scored offspring (failures and unscored entries skipped) that +did not strictly beat the best-so-far at the moment they were +produced. It is normalised to plateau = min(1, iters / _PLATEAU_PATIENCE) +with _PLATEAU_PATIENCE = 6.

    +

    Success-rate signal. _compute_success_rate(window=5) counts the +fraction of the last 5 scored offspring whose overall_score strictly +beat the running best at the moment they were produced. The classical +Rechenberg ⅕ success rule (1973) is the threshold: below 0.20 +the search is "stuck" and step size must grow; above it, real progress +is happening and step size should be damped.

    +

    Effective boldness. Combining the two signals:

    +
      +
    • Cold start (fewer than two comparable scored offspring) — + effective = 0.3 · plateau. A capped cold-start ramp avoids jumping + straight into RE-SPECIATION before any feedback has accumulated.
    • +
    • Below ⅕ (success_rate < 0.20) — average the deficit and the + plateau: effective = 0.5 · deficit + 0.5 · plateau, where + deficit = (0.20 − success_rate) / 0.20. A run of no-improvements + and a stalling success rate both push scope up.
    • +
    • Above ⅕ — damp boldness in proportion to how far above + threshold we are: effective = plateau · (1 − progress), where + progress = min(1, (success_rate − 0.20) / (0.80 − 0.20)). At + success_rate ≥ 0.80 boldness collapses regardless of plateau.
    • +
    • Near-finish floor — only in the last 5 % of the score range, + effective is multiplied by (1 − 0.5 · near_finish) where + near_finish = max(0, (parent_score − 0.95) / 0.05). This is the + only point where the parent's absolute score re-enters the boldness + calculation, so a 0.96 parent isn't gambled away one generation + before early-stop.
    • +
    • RE-SPECIATION gate. The top band is hysteresis-gated: unless + iters_since_improvement ≥ _RESPECIATION_PATIENCE (default 8) and + success_rate ∈ {None, 0.0}, effective is clamped to + _RESPECIATION_CLAMP = 0.89 — just below the EXPLORATION/RE-SPECIATION + boundary at 0.90.
    • +
    +

    Notably, parent_score no longer multiplies the whole signal — that +older behaviour locked high-score lineages into "tiny tweak" mode even +when offspring kept failing identically.

    Agent budget. The current agent count grows toward -max_possible_agents = 7 proportionally to stagnation_effective, -then a Beta-Binomial draw samples the actual count inside that window -(biased upward by stagnation). The seed generation samples agents from -[1, 4] with stagnation prior 0.5.

    +max_possible_agents = 7 proportionally to effective, then a +Beta-Binomial draw samples the actual count inside that window +(biased upward by effective). The seed generation samples agents +from [1, 4] with a 0.5 boldness prior.

    Scope band. A single advisory line is added to the mutation prompt, -chosen by stagnation_effective:

    +chosen by effective:

    - + - - + + - - + + - - + + - - + + - - + +
    Stagnation effectiveEffective boldness Mutation scope
    < 0.20prompt-only little tweak< 0.35EXPLOITATION — point mutation: minor phrasing / prompt-adjective tweaks
    < 0.40prompt, handoff, tools — improve information flow< 0.50ALIGNMENT — interface optimization: refine handoff prompts, IO contracts
    < 0.60significant redesign while keeping topology< 0.65ADAPTATION — component overhaul: rewrite lagging agent prompts, swap tools
    < 0.80bold rewire — restructure or grow the agent set< 0.90EXPLORATION — macro structural mutation: add/merge agents, change routing
    ≥ 0.80complete rethink — discard inherited topology / prompts≥ 0.90RE-SPECIATION — clean-slate redesign of the multi-agent architecture
    @@ -2283,10 +2408,13 @@

    Variation: stagnation-driven LLM can still pick any topology. The hard control is the agent-count budget passed in the same prompt block.

    Crossover

    -

    With probability ~0.3 per generation, two parents are combined instead of -one being mutated. The crossover prompt is best-parent-first: the -strongest parent's code structure leads, and weaker parents contribute -specific improvements rather than competing for the skeleton.

    +

    With probability crossover_rate per generation (default 0.1, and only +once at least initial_population = 2 runs have happened), two parents +are combined instead of one being mutated. The crossover prompt is +best-parent-first: the strongest parent's code structure leads, +weaker parents contribute specific improvements rather than competing +for the skeleton, and the offspring is hard-capped at the highest +parent agent count to prevent runaway complexity.

    Lineage & reproducibility

    After each generation, lineage.py writes a sidecar record sources/workflows/<uuid>/lineage_<uuid>.json @@ -2302,6 +2430,28 @@

    Visualisations

    +

    Run metrics artifacts

    +

    Each iteration also writes structured metrics for post-hoc analysis:

    +
      +
    • sources/workflows/<uuid>/run_metrics.json — one file per workflow. + Captures iteration, evolution_kind, parent_uuids, + iteration_wall_time_s, iteration_cost_usd, cumulative_cost_usd, + overall_score{,_uncapped}, qd_descriptor, qd_score, + novelty_score, the selection_log, and the + variation_state (iters_since_improvement, plateau, + success_rate, effective_boldness, parent_score, + respeciation_gate_open, scope_band, agent_budget) that produced + this offspring.
    • +
    • sources/workflows/qd_archive.jsonl — append-only, one line per + validate_survivor call. Records the candidate's descriptor, + qd_score, novelty_score, admission verdict, and the evicted_uuid + (if the archive was at capacity).
    • +
    • sources/workflows/variation_log.jsonl — append-only, one line per + mutation/crossover/seed prompt assembled. Lets you retrace the + Rechenberg ⅕ step-size schedule without rerunning the engine.
    • +
    +

    All three are best-effort and fail-soft: an I/O error logs a warning +but never aborts a run.

    See also