diff --git a/net_maestro/core/migrations/0011_alter_pholdsimulationconfig_lookahead.py b/net_maestro/core/migrations/0011_alter_pholdsimulationconfig_lookahead.py new file mode 100644 index 0000000..85ae55f --- /dev/null +++ b/net_maestro/core/migrations/0011_alter_pholdsimulationconfig_lookahead.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.6 on 2026-07-14 22:48 +from __future__ import annotations + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0010_alter_run_status_pholdsimulationconfig"), + ] + + operations = [ + migrations.AlterField( + model_name="pholdsimulationconfig", + name="lookahead", + field=models.FloatField( + default=1.0, + validators=[ + django.core.validators.MinValueValidator(0.1), + django.core.validators.MaxValueValidator(1.0), + ], + ), + ), + ] diff --git a/net_maestro/core/models/phold_simulation_config.py b/net_maestro/core/models/phold_simulation_config.py index c4af989..303ba4e 100644 --- a/net_maestro/core/models/phold_simulation_config.py +++ b/net_maestro/core/models/phold_simulation_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -41,7 +42,10 @@ class PHOLDSimulationConfig(models.Model): mult = models.FloatField( default=1.4, validators=[MinValueValidator(1.0)], verbose_name="Memory multiplier" ) - lookahead = models.FloatField(default=1.0, validators=[MinValueValidator(0.1)]) + # ROSS's PHOLD model hard-errors if lookahead exceeds 1.0 + lookahead = models.FloatField( + default=1.0, validators=[MinValueValidator(0.1), MaxValueValidator(1.0)] + ) start_events = models.IntegerField( default=1, validators=[MinValueValidator(1)], verbose_name="Start events per LP" ) @@ -52,3 +56,10 @@ class PHOLDSimulationConfig(models.Model): def __str__(self) -> str: return f"PHOLD config for Run {self.run_id}" + + def clean(self) -> None: + super().clean() + # PHOLD subtracts lookahead from mean before using it as the exponential distribution's + # rate parameter. A non-positive result produces invalid event timestamp offsets. + if self.mean is not None and self.lookahead is not None and self.mean <= self.lookahead: + raise ValidationError({"mean": "Mean timestamp must be greater than lookahead."}) diff --git a/net_maestro/core/templates/net_maestro/partials/new_simulation.html b/net_maestro/core/templates/net_maestro/partials/new_simulation.html index d685425..f45e985 100644 --- a/net_maestro/core/templates/net_maestro/partials/new_simulation.html +++ b/net_maestro/core/templates/net_maestro/partials/new_simulation.html @@ -57,6 +57,15 @@

{{ page_heading|default:"New Simulation"
{% csrf_token %} + {% if form.non_field_errors %} +
+
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %}
@@ -79,6 +88,9 @@

Simulation Model

Run Identifier
{{ form.run_identifier }} + {% for error in form.run_identifier.errors %} +

{{ error }}

+ {% endfor %}
@@ -97,12 +109,18 @@

Engine Parameters

Synchronization Protocol {{ form.synch }} + {% for error in form.synch.errors %} +

{{ error }}

+ {% endfor %} @@ -121,48 +139,72 @@

Model Parameters

LPs per Processor {{ form.nlp }} + {% for error in form.nlp.errors %} +

{{ error }}

+ {% endfor %} diff --git a/net_maestro/core/tests/test_phold_simulation_config.py b/net_maestro/core/tests/test_phold_simulation_config.py new file mode 100644 index 0000000..3257ae2 --- /dev/null +++ b/net_maestro/core/tests/test_phold_simulation_config.py @@ -0,0 +1,108 @@ +"""Tests for PHOLDSimulationConfig validation. + +Verifies field and model level constraints that mirror the PHOLD engine's own requirements. +""" + +from __future__ import annotations + +from django.core.exceptions import ValidationError +import pytest + +from net_maestro.core.models import PHOLDSimulationConfig, Run + + +def _build_config(**overrides: object) -> PHOLDSimulationConfig: + """Build an in-memory PHOLDSimulationConfig with valid defaults, minus overrides.""" + run = Run.objects.create(name="Test Run") + defaults: dict[str, object] = { + "run": run, + "synch": 3, + "avl_size": 18, + "nlp": 8, + "remote": 0.25, + "mean": 2.0, + "mult": 1.4, + "lookahead": 1.0, + "start_events": 1, + "memory": 100, + "stagger": False, + } + defaults.update(overrides) + return PHOLDSimulationConfig(**defaults) + + +@pytest.mark.django_db +class TestPHOLDSimulationConfigValidation: + """Test PHOLDSimulationConfig field and cross-field validation.""" + + def test_valid_config_passes_full_clean(self) -> None: + config = _build_config() + config.full_clean() + + @pytest.mark.parametrize("lookahead", [0.09, 1.01]) + def test_lookahead_out_of_range_is_invalid(self, lookahead: float) -> None: + """PHOLD hard-errors if lookahead > 1.0; keep a small positive lower bound too.""" + config = _build_config(lookahead=lookahead, mean=2.0) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "lookahead" in exc_info.value.message_dict + + def test_lookahead_at_upper_bound_is_valid(self) -> None: + config = _build_config(lookahead=1.0, mean=2.0) + config.full_clean() + + def test_mean_not_greater_than_lookahead_is_invalid(self) -> None: + """Test that mean == lookahead is invalid. + + PHOLD computes mean - lookahead as the exponential rate parameter, which must + stay positive. + """ + config = _build_config(mean=1.0, lookahead=1.0) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "mean" in exc_info.value.message_dict + + def test_mean_greater_than_lookahead_is_valid(self) -> None: + config = _build_config(mean=1.5, lookahead=1.0) + config.full_clean() + + @pytest.mark.parametrize("remote", [-0.1, 1.1]) + def test_remote_out_of_probability_range_is_invalid(self, remote: float) -> None: + """Remote is used as a probability, so it must stay within [0, 1].""" + config = _build_config(remote=remote) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "remote" in exc_info.value.message_dict + + @pytest.mark.parametrize("nlp", [0, -1]) + def test_nlp_must_be_positive(self, nlp: int) -> None: + config = _build_config(nlp=nlp) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "nlp" in exc_info.value.message_dict + + @pytest.mark.parametrize("start_events", [0, -1]) + def test_start_events_must_be_positive(self, start_events: int) -> None: + config = _build_config(start_events=start_events) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "start_events" in exc_info.value.message_dict + + def test_memory_cannot_be_negative(self) -> None: + config = _build_config(memory=-1) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "memory" in exc_info.value.message_dict + + def test_mult_below_one_is_invalid(self) -> None: + config = _build_config(mult=0.5) + + with pytest.raises(ValidationError) as exc_info: + config.full_clean() + assert "mult" in exc_info.value.message_dict diff --git a/net_maestro/core/tests/test_simulation_views.py b/net_maestro/core/tests/test_simulation_views.py index 5ca124d..83289db 100644 --- a/net_maestro/core/tests/test_simulation_views.py +++ b/net_maestro/core/tests/test_simulation_views.py @@ -57,7 +57,7 @@ def test_submit_simulation_form_save_and_run( "avl_size": "18", "nlp": "8", "remote": "0.25", - "mean": "1.0", + "mean": "2.0", "mult": "1.4", "lookahead": "1.0", "start_events": "1", @@ -87,7 +87,7 @@ def test_submit_simulation_form_save_and_run( avl_size=18, nlp=8, remote=0.25, - mean=1.0, + mean=2.0, mult=1.4, lookahead=1.0, start_events=1, @@ -105,7 +105,7 @@ def test_submit_simulation_form_save_only(self, mock_task: mock.Mock, client: Cl "avl_size": "18", "nlp": "8", "remote": "0.25", - "mean": "1.0", + "mean": "2.0", "mult": "1.4", "lookahead": "1.0", "start_events": "1", @@ -151,9 +151,38 @@ def test_submit_invalid_form(self, client: Client) -> None: # Should have form errors assert response.context["form"].errors + # Errors are rendered in the page for the user to see + assert "This field is required" in response.content.decode() + # No run should be created assert Run.objects.count() == 0 + def test_submit_invalid_form_shows_cross_field_error( + self, authenticated_client: Client + ) -> None: + """Model-level cross-field errors (e.g. mean <= lookahead) are surfaced too.""" + form_data = { + "action": "save", + "run_identifier": "Test Simulation", + "synch": "1", + "avl_size": "18", + "nlp": "8", + "remote": "0.25", + "mean": "1.0", + "mult": "1.4", + "lookahead": "1.0", + "start_events": "1", + "memory": "100", + "stagger": "0", + } + + response = authenticated_client.post(reverse("new-simulation-config"), data=form_data) + + assert response.status_code == 200 + assert "mean" in response.context["form"].errors + assert "Mean timestamp must be greater than lookahead" in response.content.decode() + assert Run.objects.count() == 0 + def test_unauthenticated_access(self, client: Client) -> None: """Test unauthenticated access returns the form (public page).""" response = client.get(reverse("new-simulation-config")) @@ -213,7 +242,7 @@ def _create_config(self) -> PHOLDSimulationConfig: avl_size=18, nlp=8, remote=0.25, - mean=1.0, + mean=2.0, mult=1.4, lookahead=1.0, start_events=1, @@ -243,7 +272,7 @@ def test_edit_save_updates_config(self, mock_task: mock.Mock, client: Client) -> "remote": "0.5", "mean": "2.0", "mult": "1.8", - "lookahead": "1.2", + "lookahead": "1.0", "start_events": "3", "memory": "200", "stagger": "1", @@ -280,7 +309,7 @@ def test_edit_clone_shows_both_runs_in_saved_list(self, client: Client) -> None: "remote": "0.4", "mean": "1.5", "mult": "1.7", - "lookahead": "1.1", + "lookahead": "1.0", "start_events": "2", "memory": "150", "stagger": "0", @@ -308,7 +337,7 @@ def test_edit_save_and_run_triggers_task(self, mock_task: mock.Mock, client: Cli "avl_size": "18", "nlp": "8", "remote": "0.25", - "mean": "1.0", + "mean": "2.0", "mult": "1.4", "lookahead": "1.0", "start_events": "1",