Skip to content

Commit d9671fb

Browse files
Merge pull request #122 from khushiiagrawal/enh/result-summary
enh: GeneticAlgorithm to track run metadata and generate results summary
2 parents a3cebc9 + 8ccf16a commit d9671fb

File tree

3 files changed

+231
-9
lines changed

3 files changed

+231
-9
lines changed

krkn_ai/algorithm/genetic.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import copy
3+
import datetime
34
import json
45
import time
56
import uuid
@@ -20,6 +21,7 @@
2021
from krkn_ai.models.config import ConfigFile
2122
from krkn_ai.reporter.generations_reporter import GenerationsReporter
2223
from krkn_ai.reporter.health_check_reporter import HealthCheckReporter
24+
from krkn_ai.reporter.json_summary_reporter import JSONSummaryReporter
2325
from krkn_ai.utils.logger import get_logger
2426
from krkn_ai.chaos_engines.krkn_runner import KrknRunner
2527
from krkn_ai.utils.rng import rng
@@ -81,6 +83,12 @@ def __init__(
8183
self.run_uuid = str(uuid.uuid4())
8284
logger.info("Krkn-AI run UUID: %s", self.run_uuid)
8385

86+
# Track run metadata for results summary
87+
self.start_time: Optional[datetime.datetime] = None
88+
self.end_time: Optional[datetime.datetime] = None
89+
self.seed: Optional[int] = None # Seed can be set externally if needed
90+
self.completed_generations: int = 0
91+
8492
if self.config.population_size < 2:
8593
raise PopulationSizeError("Population size should be at least 2")
8694

@@ -105,6 +113,7 @@ def simulate(self):
105113
self.population = self.create_population(self.config.population_size)
106114

107115
# Variables to track the progress of the algorithm
116+
self.start_time = datetime.datetime.now(datetime.timezone.utc)
108117
start_time = time.time()
109118
cur_generation = 0
110119

@@ -122,6 +131,8 @@ def simulate(self):
122131
cur_generation,
123132
format_duration(elapsed_time),
124133
)
134+
self.completed_generations = cur_generation
135+
self.end_time = datetime.datetime.now(datetime.timezone.utc)
125136
break
126137

127138
# Check if duration has been exceeded
@@ -136,6 +147,8 @@ def simulate(self):
136147
cur_generation,
137148
format_duration(elapsed_time),
138149
)
150+
self.completed_generations = cur_generation
151+
self.end_time = datetime.datetime.now(datetime.timezone.utc)
139152
break
140153
remaining_time = self.config.duration - elapsed_time
141154
logger.debug(
@@ -146,6 +159,8 @@ def simulate(self):
146159

147160
if len(self.population) == 0:
148161
logger.warning("No more population found, stopping generations.")
162+
self.completed_generations = cur_generation
163+
self.end_time = datetime.datetime.now(datetime.timezone.utc)
149164
break
150165

151166
logger.info("| Population |")
@@ -488,12 +503,24 @@ def composition(self, scenario_a: BaseScenario, scenario_b: BaseScenario):
488503

489504
def save(self):
490505
"""Save run results"""
491-
# TODO: Create a single result file (results.json) that contains summary of all the results
492506
self.generations_reporter.save_best_generations(self.best_of_generation)
493507
self.generations_reporter.save_best_generation_graph(self.best_of_generation)
494508
self.health_check_reporter.save_report(self.seen_population.values())
495509
self.health_check_reporter.sort_fitness_result_csv()
496510

511+
# Generate and save unified results summary
512+
summary_reporter = JSONSummaryReporter(
513+
run_uuid=self.run_uuid,
514+
config=self.config,
515+
seen_population=self.seen_population,
516+
best_of_generation=self.best_of_generation,
517+
start_time=self.start_time,
518+
end_time=self.end_time,
519+
completed_generations=self.completed_generations,
520+
seed=self.seed,
521+
)
522+
summary_reporter.save(self.output_dir)
523+
497524
# TODO: Send run summary to Elasticsearch
498525

499526
def save_config(self):
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""
2+
JSON Summary Reporter for generating unified results.json files.
3+
"""
4+
5+
import json
6+
import os
7+
import datetime
8+
from typing import Any, Dict, List, Optional
9+
10+
from krkn_ai.models.app import CommandRunResult
11+
from krkn_ai.models.config import ConfigFile
12+
from krkn_ai.utils.logger import get_logger
13+
14+
logger = get_logger(__name__)
15+
16+
17+
class JSONSummaryReporter:
18+
"""
19+
Reporter class for generating and saving unified JSON summary files.
20+
21+
This class consolidates all run statistics into a single results.json file
22+
for easier analysis and programmatic access.
23+
"""
24+
25+
def __init__(
26+
self,
27+
run_uuid: str,
28+
config: ConfigFile,
29+
seen_population: Dict[Any, CommandRunResult],
30+
best_of_generation: List[CommandRunResult],
31+
start_time: Optional[datetime.datetime] = None,
32+
end_time: Optional[datetime.datetime] = None,
33+
completed_generations: int = 0,
34+
seed: Optional[int] = None,
35+
):
36+
"""
37+
Initialize the JSON summary reporter.
38+
39+
Args:
40+
run_uuid: Unique identifier for this run.
41+
config: Configuration used for this run.
42+
seen_population: Map of scenarios to their execution results.
43+
best_of_generation: List of best results per generation.
44+
start_time: When the run started.
45+
end_time: When the run ended.
46+
completed_generations: Number of generations completed.
47+
seed: Random seed used for the run (if any).
48+
"""
49+
self.run_uuid = run_uuid
50+
self.config = config
51+
self.seen_population = seen_population
52+
self.best_of_generation = best_of_generation
53+
self.start_time = start_time
54+
self.end_time = end_time
55+
self.completed_generations = completed_generations
56+
self.seed = seed
57+
58+
def generate_summary(self) -> Dict[str, Any]:
59+
"""
60+
Generate a unified results summary containing all run statistics.
61+
62+
Returns:
63+
Dict containing run metadata, config summary, best scenarios,
64+
and fitness progression over generations.
65+
"""
66+
# Calculate duration
67+
duration_seconds = 0.0
68+
if self.start_time and self.end_time:
69+
duration_seconds = (self.end_time - self.start_time).total_seconds()
70+
71+
# Get all fitness scores for statistics
72+
all_fitness_scores = [
73+
result.fitness_result.fitness_score
74+
for result in self.seen_population.values()
75+
]
76+
77+
# Calculate average fitness score
78+
average_fitness_score = 0.0
79+
if all_fitness_scores:
80+
average_fitness_score = sum(all_fitness_scores) / len(all_fitness_scores)
81+
82+
# Get best fitness score
83+
best_fitness_score = 0.0
84+
if all_fitness_scores:
85+
best_fitness_score = max(all_fitness_scores)
86+
87+
# Count unique scenarios by their string representation
88+
unique_scenarios = set()
89+
for result in self.seen_population.values():
90+
unique_scenarios.add(str(result.scenario))
91+
92+
# Generate fitness progression from best_of_generation
93+
fitness_progression = self._build_fitness_progression()
94+
95+
# Generate best scenarios list (sorted by fitness score, top 10)
96+
best_scenarios = self._build_best_scenarios()
97+
98+
# Build the results summary
99+
results_summary: Dict[str, Any] = {
100+
"run_id": self.run_uuid,
101+
"seed": self.seed,
102+
"start_time": self.start_time.isoformat() if self.start_time else None,
103+
"end_time": self.end_time.isoformat() if self.end_time else None,
104+
"duration_seconds": round(duration_seconds, 2),
105+
"config": {
106+
"generations": self.config.generations,
107+
"population_size": self.config.population_size,
108+
"mutation_rate": self.config.mutation_rate,
109+
"scenario_mutation_rate": self.config.scenario_mutation_rate,
110+
"crossover_rate": self.config.crossover_rate,
111+
"composition_rate": self.config.composition_rate,
112+
},
113+
"summary": {
114+
"total_scenarios_executed": len(self.seen_population),
115+
"unique_scenarios": len(unique_scenarios),
116+
"generations_completed": self.completed_generations,
117+
"best_fitness_score": round(best_fitness_score, 4),
118+
"average_fitness_score": round(average_fitness_score, 4),
119+
},
120+
"best_scenarios": best_scenarios,
121+
"fitness_progression": fitness_progression,
122+
}
123+
124+
return results_summary
125+
126+
def _build_fitness_progression(self) -> List[Dict[str, Any]]:
127+
"""Build fitness progression data from best_of_generation."""
128+
fitness_progression = []
129+
for i, result in enumerate(self.best_of_generation):
130+
# Calculate average fitness for this generation from seen_population
131+
gen_fitness_scores = [
132+
r.fitness_result.fitness_score
133+
for r in self.seen_population.values()
134+
if r.generation_id == i
135+
]
136+
gen_average = 0.0
137+
if gen_fitness_scores:
138+
gen_average = sum(gen_fitness_scores) / len(gen_fitness_scores)
139+
140+
fitness_progression.append(
141+
{
142+
"generation": i,
143+
"best": result.fitness_result.fitness_score,
144+
"average": round(gen_average, 4),
145+
}
146+
)
147+
return fitness_progression
148+
149+
def _build_best_scenarios(self) -> List[Dict[str, Any]]:
150+
"""Build ranked list of best scenarios (top 10)."""
151+
sorted_results = sorted(
152+
self.seen_population.values(),
153+
key=lambda x: x.fitness_result.fitness_score,
154+
reverse=True,
155+
)
156+
best_scenarios = []
157+
for rank, result in enumerate(sorted_results[:10], start=1):
158+
scenario_params = {}
159+
if hasattr(result.scenario, "parameters"):
160+
scenario_params = {
161+
param.get_name(): param.get_value()
162+
for param in result.scenario.parameters
163+
}
164+
165+
best_scenarios.append(
166+
{
167+
"rank": rank,
168+
"scenario_id": result.scenario_id,
169+
"generation": result.generation_id,
170+
"fitness_score": result.fitness_result.fitness_score,
171+
"scenario_type": result.scenario.name,
172+
"parameters": scenario_params,
173+
}
174+
)
175+
return best_scenarios
176+
177+
def save(self, output_dir: str):
178+
"""
179+
Generate and save the results summary to a JSON file.
180+
181+
Args:
182+
output_dir: Directory where results.json will be saved.
183+
"""
184+
summary = self.generate_summary()
185+
output_path = os.path.join(output_dir, "results.json")
186+
with open(output_path, "w", encoding="utf-8") as f:
187+
json.dump(summary, f, indent=2)
188+
logger.info("Results summary saved to %s", output_path)

tests/unit/algorithm/test_genetic_algorithm.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,19 @@ def test_save_method_calls_reporters(self, genetic_algorithm):
8181
genetic_algorithm.health_check_reporter,
8282
"sort_fitness_result_csv",
8383
) as mock_sort:
84-
genetic_algorithm.best_of_generation = [Mock()]
85-
genetic_algorithm.seen_population = {Mock(): Mock()}
86-
genetic_algorithm.save()
84+
with patch(
85+
"krkn_ai.algorithm.genetic.JSONSummaryReporter"
86+
) as mock_summary_reporter:
87+
mock_reporter_instance = Mock()
88+
mock_summary_reporter.return_value = mock_reporter_instance
89+
genetic_algorithm.best_of_generation = [Mock()]
90+
genetic_algorithm.seen_population = {Mock(): Mock()}
91+
genetic_algorithm.save()
8792

88-
# Verify all reporter methods are called
89-
assert mock_save_gen.called
90-
assert mock_graph.called
91-
assert mock_save_report.called
92-
assert mock_sort.called
93+
# Verify all reporter methods are called
94+
assert mock_save_gen.called
95+
assert mock_graph.called
96+
assert mock_save_report.called
97+
assert mock_sort.called
98+
assert mock_summary_reporter.called
99+
assert mock_reporter_instance.save.called

0 commit comments

Comments
 (0)