File size: 26,125 Bytes
585cd37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0b8672
585cd37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0b8672
585cd37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69a2c9c
 
585cd37
 
69a2c9c
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
"""Integration tests for the full simulator."""
from agentic_rl.engine.simulator import FishFarmSimulator


class TestSimulatorBasics:
    def test_reset_creates_valid_state(self):
        sim = FishFarmSimulator(seed=42)
        state = sim.reset()
        assert state["fish"]["weight_g"] > 0
        assert state["fish"]["population"] > 0
        assert state["water"]["DO"] > 0
        assert state["water"]["temperature"] > 0

    def test_step_advances_time(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        state = sim.step(feeding_rate=0.5, aeration_rate=0.5,
                        heater_setting=0.0, water_exchange_rate=0.01,
                        harvest=False, treatment="none")
        assert state["time"]["hour"] == 1

    def test_24_hours_equals_one_day(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        for _ in range(24):
            state = sim.step(0.5, 0.5, 0.0, 0.01, False, "none")
        assert state["time"]["day"] == 1

    def test_overfeeding_causes_ammonia_rise(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        initial_tan = sim.water.TAN
        for _ in range(48):  # 2 days of overfeeding
            sim.step(1.0, 0.3, 0.0, 0.0, False, "none")  # max feed, low aeration, no exchange
        assert sim.water.TAN > initial_tan

    def test_no_aeration_causes_do_drop(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        for _ in range(12):  # 12 hours nighttime without aeration
            sim.step(0.0, 0.0, 0.0, 0.0, False, "none")
        assert sim.water.DO < 7.0  # should drop from initial

    def test_fish_grow_over_time(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        initial_weight = sim.fish.weight_g
        for _ in range(24 * 7):  # 1 week
            sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
        assert sim.fish.weight_g > initial_weight

    def test_harvest_ends_episode(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        state = sim.step(0.5, 0.5, 0.0, 0.01, True, "none")  # harvest=True
        assert state["harvested"] is True

    def test_mass_mortality_is_catastrophe(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        # Force lethal conditions
        sim.water.DO = 0.5
        sim.water.TAN = 5.0
        sim.water.temperature = 40.0
        state = sim.step(0.0, 0.0, 0.0, 0.0, False, "none")
        assert state["fish"]["mortality_today"] > 0

    def test_cascade_overfeed_to_mortality(self):
        """The signature RL challenge: overfeed -> ammonia -> DO crash -> deaths."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        # Heavy overfeeding for 3 days with no aeration or exchange
        for _ in range(72):
            sim.step(1.0, 0.0, 0.0, 0.0, False, "none")
        # Should see elevated ammonia and reduced survival
        assert sim.water.TAN > 1.0 or sim.fish.population < 10000

    def test_state_includes_enhanced_economics(self):
        """State dict should include new economics fields from engine enhancement."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
        econ = state["economics"]
        assert "feed_price_per_kg" in econ
        assert "marginal_cost_per_hour" in econ
        assert "roi_pct" in econ
        assert econ["feed_price_per_kg"] > 0

    def test_stochastic_feed_price_varies(self):
        """Feed price should vary stochastically over time."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        prices = []
        for _ in range(48):
            state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
            prices.append(state["economics"]["feed_price_per_kg"])
        # Price should not be perfectly constant (OU process adds noise)
        assert len(set(prices)) > 1

    def test_seasonal_price_varies_by_day(self):
        """Market price multiplier should reflect seasonal demand."""
        from agentic_rl.engine.economics import EconomicsEngine
        econ = EconomicsEngine()
        econ.reset()

        econ.apply_seasonal_price(day_of_year=360)  # Christmas → premium
        xmas_price = econ.market_price_multiplier

        econ.market_price_multiplier = 1.0  # reset
        econ.apply_seasonal_price(day_of_year=180)  # mid-year → dip
        midyear_price = econ.market_price_multiplier

        assert xmas_price > midyear_price

    def test_vaccination_treatment_option(self):
        """Vaccination should move susceptible fish to recovered."""
        from agentic_rl.engine.disease import DiseaseEngine
        de = DiseaseEngine()
        de.reset(population=10000)
        initial_susceptible = de.susceptible

        de.apply_treatment("vaccination")
        assert de.recovered > 0
        assert de.susceptible < initial_susceptible
        # 80% should be vaccinated
        assert de.recovered >= int(initial_susceptible * 0.79)

    def test_temperature_affects_disease_virulence(self):
        """Disease should progress differently at different temperatures."""
        from agentic_rl.engine.disease import DiseaseEngine
        de_warm = DiseaseEngine()
        de_warm.reset(population=10000)
        de_warm.trigger_outbreak(50)

        de_cold = DiseaseEngine()
        de_cold.reset(population=10000)
        de_cold.trigger_outbreak(50)

        # Run for 5 days
        for _ in range(120):
            de_warm.step(1.0, de_warm.susceptible + de_warm.exposed +
                        de_warm.infected + de_warm.recovered, temperature=30.0)
            de_cold.step(1.0, de_cold.susceptible + de_cold.exposed +
                        de_cold.infected + de_cold.recovered, temperature=15.0)

        # Warm conditions should produce more disease deaths
        assert de_warm.total_disease_deaths >= de_cold.total_disease_deaths


class TestObservationCompleteness:
    """Verify the FarmObservation includes all enhanced fields."""

    def test_observation_has_fish_growth_fields(self):
        """Observation should include FCR, SGR, growth rate, stocking density."""
        from agentic_rl.server.environment import FishFarmEnvironment
        env = FishFarmEnvironment()
        obs = env.reset(task_id="feeding_basics")
        assert hasattr(obs, "fcr")
        assert hasattr(obs, "sgr")
        assert hasattr(obs, "growth_rate_g_day")
        assert hasattr(obs, "stocking_density")

    def test_observation_has_economics_fields(self):
        """Observation should include stochastic feed price, ROI, marginal cost."""
        from agentic_rl.server.environment import FishFarmEnvironment
        from agentic_rl.models import FarmAction
        env = FishFarmEnvironment()
        env.reset(task_id="feeding_basics")
        step_obs = env.step(FarmAction(feeding_rate=0.5, aeration_rate=0.5))
        assert hasattr(step_obs, "feed_price_per_kg")
        assert hasattr(step_obs, "market_price_multiplier")
        assert hasattr(step_obs, "marginal_cost_per_hour")
        assert hasattr(step_obs, "roi_pct")
        assert step_obs.feed_price_per_kg > 0

    def test_observation_has_weather_fields(self):
        """Observation should include daytime, storm, humidity."""
        from agentic_rl.server.environment import FishFarmEnvironment
        env = FishFarmEnvironment()
        obs = env.reset(task_id="feeding_basics")
        assert hasattr(obs, "is_daytime")
        assert hasattr(obs, "storm_active")
        assert hasattr(obs, "humidity")

    def test_observation_has_disease_signal(self):
        """Observation should have disease_suspected (behavioral indicator)."""
        from agentic_rl.server.environment import FishFarmEnvironment
        env = FishFarmEnvironment()
        obs = env.reset(task_id="feeding_basics")
        assert hasattr(obs, "disease_suspected")
        # No disease initially
        assert obs.disease_suspected is False

    def test_observation_has_survival_fields(self):
        """Observation should include cumulative mortality and survival rate."""
        from agentic_rl.server.environment import FishFarmEnvironment
        env = FishFarmEnvironment()
        obs = env.reset(task_id="feeding_basics")
        assert hasattr(obs, "cumulative_mortality")
        assert hasattr(obs, "survival_rate")
        assert obs.survival_rate == 1.0

    def test_observation_has_nitrate_and_algae(self):
        """Observation should include NO3 and algae bloom status."""
        from agentic_rl.server.environment import FishFarmEnvironment
        env = FishFarmEnvironment()
        obs = env.reset(task_id="feeding_basics")
        assert hasattr(obs, "nitrate")
        assert hasattr(obs, "algae_bloom")


class TestHeuristicAgent:
    """Test the rule-based heuristic fallback agent."""

    def test_heuristic_reduces_feed_on_low_do(self):
        from inference import heuristic_action
        obs = {"dissolved_oxygen": 2.0, "ammonia_toxic": 0.01,
               "temperature": 28.0, "stress_level": 0.3,
               "feeding_response": "sluggish", "avg_fish_weight": 100.0,
               "population": 5000, "feed_remaining_kg": 200.0,
               "biofilter_working": True, "aerator_working": True,
               "disease_suspected": False, "is_daytime": True,
               "market_price_multiplier": 1.0}
        action = heuristic_action(obs, "feeding_basics", 10, 168)
        assert action["feeding_rate"] <= 0.2
        assert action["aeration_rate"] == 1.0  # emergency DO

    def test_heuristic_treats_disease(self):
        from inference import heuristic_action
        obs = {"dissolved_oxygen": 6.0, "ammonia_toxic": 0.01,
               "temperature": 28.0, "stress_level": 0.5,
               "feeding_response": "sluggish", "avg_fish_weight": 200.0,
               "population": 5000, "feed_remaining_kg": 200.0,
               "biofilter_working": True, "aerator_working": True,
               "disease_suspected": True, "mortality_today": 15,
               "is_daytime": True, "market_price_multiplier": 1.0}
        action = heuristic_action(obs, "disease_outbreak", 50, 240)
        assert action["treatment"] == "antibiotics"

    def test_heuristic_harvests_at_market_weight(self):
        from inference import heuristic_action
        obs = {"dissolved_oxygen": 7.0, "ammonia_toxic": 0.01,
               "temperature": 28.0, "stress_level": 0.1,
               "feeding_response": "eager", "avg_fish_weight": 550.0,
               "population": 5000, "feed_remaining_kg": 200.0,
               "biofilter_working": True, "aerator_working": True,
               "disease_suspected": False, "is_daytime": True,
               "market_price_multiplier": 1.15, "mortality_today": 0}
        action = heuristic_action(obs, "full_growout", 1400, 1440)
        assert action["harvest_decision"] is True

    def test_heuristic_heats_cold_water(self):
        from inference import heuristic_action
        obs = {"dissolved_oxygen": 7.0, "ammonia_toxic": 0.01,
               "temperature": 22.0, "stress_level": 0.2,
               "feeding_response": "normal", "avg_fish_weight": 100.0,
               "population": 5000, "feed_remaining_kg": 200.0,
               "biofilter_working": True, "aerator_working": True,
               "disease_suspected": False, "is_daytime": True,
               "market_price_multiplier": 1.0, "mortality_today": 0}
        action = heuristic_action(obs, "temperature_stress", 10, 120)
        assert action["heater_setting"] > 0

    def test_heuristic_increases_exchange_for_high_ammonia(self):
        from inference import heuristic_action
        obs = {"dissolved_oxygen": 6.0, "ammonia_toxic": 0.15, "ammonia": 2.5,
               "temperature": 28.0, "stress_level": 0.3,
               "feeding_response": "sluggish", "avg_fish_weight": 150.0,
               "population": 5000, "feed_remaining_kg": 200.0,
               "biofilter_working": True, "aerator_working": True,
               "disease_suspected": False, "is_daytime": True,
               "market_price_multiplier": 1.0, "mortality_today": 0}
        action = heuristic_action(obs, "ammonia_crisis", 10, 72)
        assert action["water_exchange_rate"] >= 0.05


class TestStochasticGrowth:
    """Test stochastic growth noise (KB-03 Sec 9.2)."""

    def test_growth_has_variance_across_seeds(self):
        """Different seeds should produce slightly different growth outcomes."""
        weights = []
        for seed in [1, 2, 3, 4, 5]:
            sim = FishFarmSimulator(seed=seed)
            sim.reset(seed=seed)
            for _ in range(24):
                sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
            weights.append(sim.fish.weight_g)
        # All should be close (same conditions) but not identical (stochastic noise)
        assert max(weights) > min(weights)  # some variation exists
        # But within reasonable bounds (<2% spread for 24h)
        spread = (max(weights) - min(weights)) / min(weights)
        assert spread < 0.05  # less than 5% spread in 24h

    def test_deterministic_with_same_seed(self):
        """Same seed should produce identical results."""
        results = []
        for _ in range(2):
            sim = FishFarmSimulator(seed=42)
            sim.reset(seed=42)
            for _ in range(24):
                sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
            results.append(sim.fish.weight_g)
        assert results[0] == results[1]


class TestNighttimeDORisk:
    """Test nighttime DO crash risk tracking."""

    def test_state_includes_nighttime_do_risk(self):
        sim = FishFarmSimulator(seed=42)
        state = sim.reset()
        assert "nighttime_do_risk" in state["water"]
        assert 0.0 <= state["water"]["nighttime_do_risk"] <= 1.0

    def test_observation_has_nighttime_do_risk(self):
        from agentic_rl.server.environment import FishFarmEnvironment
        env = FishFarmEnvironment()
        obs = env.reset(task_id="feeding_basics")
        assert hasattr(obs, "nighttime_do_risk")
        assert 0.0 <= obs.nighttime_do_risk <= 1.0

    def test_high_algae_increases_nighttime_risk(self):
        """Algae bloom should raise nighttime DO crash risk."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        # Force algae bloom
        sim.water.chlorophyll_a = 100.0
        # Run through a day-night cycle (24h)
        for _ in range(24):
            sim.step(0.5, 0.3, 0.0, 0.02, False, "none")
        # Risk should be non-zero with high algae
        assert sim.water.nighttime_do_risk >= 0.0

    def test_heuristic_boosts_aeration_on_high_risk(self):
        """Heuristic should increase aeration when nighttime DO risk is high."""
        from inference import heuristic_action
        obs = {"dissolved_oxygen": 6.0, "ammonia_toxic": 0.01,
               "temperature": 28.0, "stress_level": 0.1,
               "feeding_response": "normal", "avg_fish_weight": 100.0,
               "population": 5000, "feed_remaining_kg": 200.0,
               "biofilter_working": True, "aerator_working": True,
               "disease_suspected": False, "is_daytime": False,
               "market_price_multiplier": 1.0, "mortality_today": 0,
               "nighttime_do_risk": 0.8}
        action = heuristic_action(obs, "oxygen_management", 10, 72)
        assert action["aeration_rate"] >= 0.9  # should boost for high risk


class TestHeatWaveEvent:
    """Test that heat_wave events actually raise water temperature."""

    def test_heat_wave_raises_temperature(self):
        """Heat wave event should increase water temperature over time."""
        from agentic_rl.engine.events import Event
        sim = FishFarmSimulator(seed=42)
        sim.reset(
            initial_temp=28.0,
            base_air_temp=30.0,
            scheduled_events=[
                Event(type="heat_wave", trigger_hour=0, severity=0.7,
                      duration_hours=48, description="Heat wave test"),
            ],
        )
        # Run 24 hours with heat wave active
        for _ in range(24):
            sim.step(0.3, 0.5, 0.0, 0.02, False, "none")
        # Water should warm up from the heat wave
        assert sim.water.temperature > 28.0

    def test_heat_wave_ends_correctly(self):
        """After heat wave ends, temperature should not keep rising."""
        from agentic_rl.engine.events import Event
        sim = FishFarmSimulator(seed=42)
        sim.reset(
            initial_temp=28.0,
            base_air_temp=28.0,
            scheduled_events=[
                Event(type="heat_wave", trigger_hour=0, severity=0.7,
                      duration_hours=6, description="Short heat wave"),
            ],
        )
        # Run through the 6-hour heat wave
        for _ in range(6):
            sim.step(0.3, 0.5, 0.0, 0.02, False, "none")
        temp_at_end = sim.water.temperature
        # Run 12 more hours after heat wave ended — temp should stabilize/drop
        for _ in range(12):
            sim.step(0.3, 0.5, -0.3, 0.02, False, "none")  # slight cooling
        # Should not have increased further (heat wave is over)
        assert sim.water.temperature <= temp_at_end + 1.0  # small tolerance for thermal inertia


class TestVaccinationProphylaxis:
    """Test that vaccination works as preventive measure (KB-03 Sec 4.2)."""

    def test_vaccination_without_active_disease(self):
        """Vaccination should work even when no disease is active."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        assert sim.disease.is_active is False
        initial_susceptible = sim.disease.susceptible
        sim.step(0.5, 0.5, 0.0, 0.02, False, "vaccination")
        # 80% of susceptible should be vaccinated (moved to recovered)
        assert sim.disease.recovered > 0
        assert sim.disease.susceptible < initial_susceptible

    def test_vaccination_cost_charged(self):
        """Vaccination cost should be recorded even without active disease."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        sim.step(0.5, 0.5, 0.0, 0.02, False, "vaccination")
        assert sim.economics.total_treatment_cost > 0

    def test_antibiotics_blocked_without_disease(self):
        """Non-vaccination treatments should NOT apply without active disease."""
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        sim.step(0.5, 0.5, 0.0, 0.02, False, "antibiotics")
        assert sim.economics.total_treatment_cost == 0.0


class TestCostBreakdown:
    """Test that cost breakdown is exposed in state dict."""

    def test_state_includes_cost_breakdown(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
        assert "cost_breakdown" in state["economics"]
        breakdown = state["economics"]["cost_breakdown"]
        assert "feed" in breakdown
        assert "energy" in breakdown
        assert "total" in breakdown

    def test_cost_breakdown_components_sum(self):
        sim = FishFarmSimulator(seed=42)
        sim.reset()
        for _ in range(24):
            state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none")
        breakdown = state["economics"]["cost_breakdown"]
        component_sum = sum(
            v["amount"] for v in breakdown.values() if isinstance(v, dict)
        )
        assert abs(component_sum - breakdown["total"]) < 0.1


class TestHarvestRevenue:
    """Test weight-dependent harvest revenue."""

    def test_harvest_revenue_uses_weight_premium(self):
        """Harvest revenue should reflect weight-dependent pricing."""
        from agentic_rl.engine.economics import EconomicsEngine
        econ = EconomicsEngine()
        econ.reset()
        # Underweight fish should get less revenue than market-weight fish
        rev_small = econ.calculate_harvest_revenue(100.0, avg_weight_g=100.0)
        rev_large = econ.calculate_harvest_revenue(100.0, avg_weight_g=500.0)
        assert rev_large > rev_small

    def test_harvest_matches_fish_value(self):
        """Harvest revenue should equal fish value (same pricing curve)."""
        from agentic_rl.engine.economics import EconomicsEngine
        econ = EconomicsEngine()
        econ.reset()
        value = econ.calculate_fish_value(200.0, avg_weight_g=350.0)
        revenue = econ.calculate_harvest_revenue(200.0, avg_weight_g=350.0)
        assert abs(value - revenue) < 0.01


class TestTaskSpecificHeuristics:
    """Test task-specific heuristic strategies in inference.py."""

    def _base_obs(self, **overrides):
        obs = {
            "dissolved_oxygen": 6.5, "ammonia_toxic": 0.01, "ammonia": 0.2,
            "nitrite": 0.05, "temperature": 28.0, "stress_level": 0.1,
            "feeding_response": "normal", "avg_fish_weight": 150.0,
            "population": 5000, "feed_remaining_kg": 200.0,
            "biofilter_working": True, "aerator_working": True,
            "disease_suspected": False, "is_daytime": True,
            "market_price_multiplier": 1.0, "mortality_today": 0,
            "nighttime_do_risk": 0.1, "feed_price_per_kg": 0.50,
            "water_quality_score": 0.85, "algae_bloom": False,
        }
        obs.update(overrides)
        return obs

    def test_storm_pre_positioning(self):
        """Storm response: pre-storm phase should boost aeration and reduce feeding."""
        from inference import heuristic_action
        obs = self._base_obs()
        action = heuristic_action(obs, "storm_response", step=10, max_hours=120)
        assert action["aeration_rate"] >= 0.8
        assert action["water_exchange_rate"] >= 0.04

    def test_storm_power_outage_minimal_feeding(self):
        """During power outage (h24-36), feeding should be minimal."""
        from inference import heuristic_action
        obs = self._base_obs(aerator_working=False)
        action = heuristic_action(obs, "storm_response", step=28, max_hours=120)
        assert action["feeding_rate"] <= 0.1

    def test_ammonia_crisis_aggressive_exchange(self):
        """Ammonia crisis with high UIA should trigger aggressive water exchange."""
        from inference import heuristic_action
        obs = self._base_obs(ammonia_toxic=0.08, ammonia=1.5, biofilter_working=False)
        action = heuristic_action(obs, "ammonia_crisis", step=5, max_hours=72)
        assert action["water_exchange_rate"] >= 0.06
        assert action["feeding_rate"] <= 0.15

    def test_disease_outbreak_early_vaccination(self):
        """Disease outbreak task: should vaccinate at step 1 (before h12 trigger)."""
        from inference import heuristic_action
        obs = self._base_obs()
        action = heuristic_action(obs, "disease_outbreak", step=1, max_hours=240)
        assert action["treatment"] == "vaccination"

    def test_multi_objective_stress_reduction(self):
        """Multi-objective: high stress should reduce feeding for welfare."""
        from inference import heuristic_action
        obs = self._base_obs(stress_level=0.35)
        action = heuristic_action(obs, "multi_objective", step=100, max_hours=720)
        assert action["feeding_rate"] <= 0.35
        # Multi-objective minimizes aeration when DO is good to save costs
        assert action["aeration_rate"] <= 0.4

    def test_temperature_stress_cooling(self):
        """Temperature stress: hot temps should trigger cooling + more aeration."""
        from inference import heuristic_action
        obs = self._base_obs(temperature=35.0)
        action = heuristic_action(obs, "temperature_stress", step=30, max_hours=120)
        assert action["aeration_rate"] >= 0.85
        assert action["feeding_rate"] <= 0.3

    def test_nitrite_triggers_salt_treatment(self):
        """High nitrite should trigger salt treatment."""
        from inference import heuristic_action
        obs = self._base_obs(nitrite=0.8)
        action = heuristic_action(obs, "water_quality_balance", step=10, max_hours=168)
        assert action["treatment"] == "salt"

    def test_full_growout_harvest_at_market_weight(self):
        """Full growout: harvest when weight >= 400 near episode end."""
        from inference import heuristic_action
        obs = self._base_obs(avg_fish_weight=460.0, market_price_multiplier=1.05)
        # hours_left = 1440 - 1420 = 20 → triggers weight >= 400 and hours_left <= 24
        action = heuristic_action(obs, "full_growout", step=1420, max_hours=1440)
        assert action["harvest_decision"] is True

    def test_season_management_conserve_low_feed(self):
        """Season management: conserve feed when inventory critically low."""
        from inference import heuristic_action
        obs = self._base_obs(feed_remaining_kg=25.0)  # < 30 triggers min 0.2
        action = heuristic_action(obs, "season_management", step=500, max_hours=2160)
        assert action["feeding_rate"] <= 0.25

    def test_feed_price_sensitivity(self):
        """Expensive feed should reduce feeding rate by 15%."""
        from inference import heuristic_action
        obs_cheap = self._base_obs(feed_price_per_kg=0.40)
        obs_expensive = self._base_obs(feed_price_per_kg=0.70)
        action_cheap = heuristic_action(obs_cheap, "feeding_basics", step=10, max_hours=168)
        action_expensive = heuristic_action(obs_expensive, "feeding_basics", step=10, max_hours=168)
        assert action_expensive["feeding_rate"] < action_cheap["feeding_rate"]

    def test_catastrophe_survives_through_crises(self):
        """Catastrophe: endure crises, harvest after engagement threshold (≥84h)."""
        from inference import heuristic_action
        obs = self._base_obs(avg_fish_weight=250.0)
        # Early: should NOT harvest (engagement penalty in grader)
        action_early = heuristic_action(obs, "catastrophe_prevention", step=2, max_hours=336)
        assert action_early["harvest_decision"] is False
        # After engagement threshold: harvest to lock in survival
        action_late = heuristic_action(obs, "catastrophe_prevention", step=90, max_hours=336)
        assert action_late["harvest_decision"] is True