Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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),
],
),
),
]
13 changes: 12 additions & 1 deletion net_maestro/core/models/phold_simulation_config.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"
)
Expand All @@ -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."})
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ <h1 class="text-3xl font-semibold mt-2">{{ page_heading|default:"New Simulation"

<form id="simulation-form" method="post" action="{{ form_action|default:request.path }}">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-error mt-8">
<ul>
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="grid grid-cols-1 gap-6 mt-8">
<!-- Simulation Model Selection -->
<div class="card bg-base-100 shadow-sm">
Expand All @@ -79,6 +88,9 @@ <h2 class="card-title mb-4">Simulation Model</h2>
<span class="label-text">Run Identifier</span>
</div>
{{ form.run_identifier }}
{% for error in form.run_identifier.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
</div>
</div>
Expand All @@ -97,12 +109,18 @@ <h2 class="card-title">Engine Parameters</h2>
<span class="label-text">Synchronization Protocol</span>
</div>
{{ form.synch }}
{% for error in form.synch.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">AVL Tree Size</span>
</div>
{{ form.avl_size }}
{% for error in form.avl_size.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
</div>
</div>
Expand All @@ -121,48 +139,72 @@ <h2 class="card-title">Model Parameters</h2>
<span class="label-text">LPs per Processor</span>
</div>
{{ form.nlp }}
{% for error in form.nlp.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Remote Event Rate (0-1)</span>
</div>
{{ form.remote }}
{% for error in form.remote.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Mean Timestamp</span>
</div>
{{ form.mean }}
{% for error in form.mean.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Lookahead</span>
</div>
{{ form.lookahead }}
{% for error in form.lookahead.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Start Events per LP</span>
</div>
{{ form.start_events }}
{% for error in form.start_events.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Memory Multiplier</span>
</div>
{{ form.mult }}
{% for error in form.mult.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Additional Memory Buffers</span>
</div>
{{ form.memory }}
{% for error in form.memory.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Stagger Events</span>
</div>
{{ form.stagger }}
{% for error in form.stagger.errors %}
<p class="text-error text-sm mt-1">{{ error }}</p>
{% endfor %}
</label>
</div>
</div>
Expand Down
108 changes: 108 additions & 0 deletions net_maestro/core/tests/test_phold_simulation_config.py
Original file line number Diff line number Diff line change
@@ -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
43 changes: 36 additions & 7 deletions net_maestro/core/tests/test_simulation_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading