Skip to content
Open
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
55 changes: 50 additions & 5 deletions rust-bindings/src/model/step_param_space.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,52 @@ pub(crate) struct PyStepParameterSpaceIterator {
/// every `NodeIterator` impl is `Send + Sync` (enforced at the
/// trait bound in `openjd-model`).
iter: Mutex<StepParameterSpaceIterator>,
/// The `chunks_task_count_override` this was constructed with, kept
/// so `__getitem__` can rebuild an equivalent iterator. Without it,
/// random access would silently fall back to the template's own
/// `defaultTaskCount` and disagree with iteration.
chunk_override: Option<usize>,
}

#[cfg_attr(feature = "stub-gen", gen_stub_pymethods)]
#[pymethods]
impl PyStepParameterSpaceIterator {
/// Construct an iterator over a step's parameter space.
///
/// `chunks_task_count_override` overrides the `defaultTaskCount` of a
/// `CHUNK[INT]` parameter and turns adaptive chunking off, so a chunked space
/// can be walked at a caller-chosen granularity. Pass `1` to iterate individual
/// tasks. Ignored when the space has no chunked parameter, matching the
Comment thread
leongdl marked this conversation as resolved.
/// pure-Python reference — though a non-positive value is still rejected in
/// that case, since validating an argument is cheaper to reason about than
/// silently discarding a bad one.
///
/// Without this, a statically chunked space could only be walked at the
/// template's own chunk size: `chunks_default_task_count` is settable for
/// adaptive spaces only.
#[new]
#[pyo3(signature = (*, step=None, space=None))]
fn new(step: Option<&PyStep>, space: Option<&PyStepParameterSpace>) -> PyResult<Self> {
#[pyo3(signature = (*, step=None, space=None, chunks_task_count_override=None))]
fn new(
step: Option<&PyStep>,
space: Option<&PyStepParameterSpace>,
chunks_task_count_override: Option<i64>,
) -> PyResult<Self> {
// Taken as i64, not usize, so a negative value reaches this check instead of
// failing pyo3's unsigned extraction with OverflowError. Every non-positive
// value should report the same ValueError the message and the spec promise.
//
// Rejected rather than clamped: openjd-model applies `.max(1)` to the override,
// so 0 would silently mean 1. The `chunks_default_task_count` setter already
// refuses 0, and the two should not disagree.
let chunks_task_count_override: Option<usize> = match chunks_task_count_override {
Some(n) if n <= 0 => {
Comment thread
leongdl marked this conversation as resolved.
return Err(pyo3::exceptions::PyValueError::new_err(
"chunks_task_count_override must be a positive integer.",
));
}
Some(n) => Some(n as usize),
None => None,
};
let ps = if let Some(s) = space {
s.inner.clone()
} else if let Some(st) = step {
Expand All @@ -148,14 +186,17 @@ impl PyStepParameterSpaceIterator {
combination: None,
}
};
let iter = StepParameterSpaceIterator::new(&ps).map_err(model_err_to_py)?;
let iter =
StepParameterSpaceIterator::new_with_chunk_override(&ps, chunks_task_count_override)
.map_err(model_err_to_py)?;
let len = iter.len();
let names = iter.names().clone();
Ok(Self {
space: ps,
len,
names,
iter: Mutex::new(iter),
chunk_override: chunks_task_count_override,
})
}

Expand Down Expand Up @@ -185,8 +226,12 @@ impl PyStepParameterSpaceIterator {
index as usize
};
// Random access uses a fresh iterator — don't disturb the
// persistent iter's cursor or its adaptive Arc.
let iter = StepParameterSpaceIterator::new(&self.space).map_err(model_err_to_py)?;
// persistent iter's cursor or its adaptive Arc. It must carry the
// same chunk override, or indexing would report chunks that
// iteration never yields.
let iter =
StepParameterSpaceIterator::new_with_chunk_override(&self.space, self.chunk_override)
Comment thread
leongdl marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invariant this change codifies — "indexing must not report chunks that iteration never yields" — is still violated on the setter path, which this PR leaves untouched.

set_chunks_default_task_count (line 320) mutates only the persistent self.iter (via its internal adaptive Arc<AtomicUsize>). __getitem__ rebuilds from self.space + self.chunk_override, and chunk_override is None for an adaptive space that took no constructor override. So for an adaptive CHUNK[INT] space:

it = StepParameterSpaceIterator(step=step)   # template defaultTaskCount = 10
it.chunks_default_task_count = 5
next(it)        # a 5-task chunk, e.g. "1-5"
it[0]           # rebuilt fresh at defaultTaskCount=10 -> "1-10"

self.len (line 112) has the same staleness: it is captured at construction, so the negative-index adjustment at line 218 uses the pre-mutation count. (__len__ itself raises for adaptive spaces, so the stale value is only observable through negative indexing.)

Two options that would close it without much code: have __getitem__ read the current chunk size off the live iter (iter.chunks_default_task_count()) and pass that as the override when the space is adaptive, or have the setter update a stored effective-chunk-size field that __getitem__ and the negative-index math both consult.

Not introduced here, but the comment added on these lines now asserts the property, and it does not hold for the mutation route.

.map_err(model_err_to_py)?;
match iter.get(idx) {
Some(params) => task_param_set_to_py(py, &params),
None => Err(pyo3::exceptions::PyIndexError::new_err(
Expand Down
41 changes: 41 additions & 0 deletions specs/python-model-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,8 @@ from openjd.model._v1.job import StepParameterSpaceIterator

it = StepParameterSpaceIterator(step=job.steps[0])
# or: it = StepParameterSpaceIterator(space=step.parameterSpace)
# or, to walk a chunked space one task at a time:
# it = StepParameterSpaceIterator(space=..., chunks_task_count_override=1)

len(it) # total task count, e.g. 10
it[0] # {"Frame": 1}
Expand All @@ -1150,6 +1152,45 @@ non-trivial state for chunked spaces). Indexing (``it[i]``) is
unaffected by iteration position; ``__contains__`` is also
non-mutating.

`chunks_task_count_override` replaces the `defaultTaskCount` of a
`CHUNK[INT]` parameter and turns adaptive chunking off, so a chunked
space can be walked at a granularity the caller picks. Pass `1` to
iterate individual tasks — a `1-20` range chunked five at a time then
yields `1-1`, `2-2`, … instead of `1-5`, `6-10`, …. It is ignored when
Comment thread
leongdl marked this conversation as resolved.
the space has no chunked parameter, matching the pure-Python reference,
although a non-positive value is still rejected in that case.
Iteration observes it, and `len()` counts the overridden granularity.

The rendered form of a chunk depends on `rangeConstraint`, which matters
because these are strings a consumer parses and may feed back through
`__contains__`. A single-task chunk is `"1-1"` under `CONTIGUOUS` and a
bare `"1"` under `NONCONTIGUOUS`.

It is currently the only way to change the chunk size of a *static*
chunked space: the `chunks_default_task_count` setter accepts adaptive
spaces only, and raises `ValueError` otherwise.
Comment thread
leongdl marked this conversation as resolved.

Any non-positive value raises `ValueError`, so one `except ValueError`
covers `0` and negatives alike. The Rust layer clamps the override to at
least 1, so 0 would otherwise silently mean 1, and the
`chunks_default_task_count` setter already rejects it. The pure-Python
reference does not validate this argument.

Two current-implementation limitations, both divergences from the v0
reference rather than intended behaviour. Each has a `strict` xfail in
`test/openjd/model_v1/test_known_gaps.py`, so clearing either will fail
CI until this text is updated with it.

- Indexing observes the override only for a space that supports random
access. A `CONTIGUOUS` chunked space requires sequential iteration, so
`it[i]` raises `IndexError` for any index — with or without the
override — even though `len(it)` reports a count. See
`test_a_contiguous_chunked_space_supports_indexing`.
- `chunks_parameter_name` and `chunks_default_task_count` both return
`None` once the space is non-adaptive, which supplying the override
makes it. v0 reports the parameter name and the override value. See
Comment thread
leongdl marked this conversation as resolved.
`test_chunk_metadata_is_reported_for_a_non_adaptive_space`.

### `StepDependencyGraph`

Step dependency graph for topological ordering.
Expand Down
18 changes: 17 additions & 1 deletion src/openjd/_openjd_rs.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2381,7 +2381,23 @@ class StepParameterSpaceIterator:
*,
step: typing.Optional[Step] = None,
space: typing.Optional[StepParameterSpace] = None,
) -> StepParameterSpaceIterator: ...
chunks_task_count_override: typing.Optional[builtins.int] = None,
) -> StepParameterSpaceIterator:
r"""
Construct an iterator over a step's parameter space.

`chunks_task_count_override` overrides the `defaultTaskCount` of a
`CHUNK[INT]` parameter and turns adaptive chunking off, so a chunked space
can be walked at a caller-chosen granularity. Pass `1` to iterate individual
tasks. Ignored when the space has no chunked parameter, matching the
pure-Python reference -- though a non-positive value is still rejected in
Comment thread
leongdl marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring looks hand-edited rather than regenerated, so the checked-in stub will not round-trip through scripts/generate_stubs.sh.

The Rust source (step_param_space.rs:141-143) writes an em dash:

pure-Python reference — though a non-positive value is still rejected

but here it is --, and the trailing clause "since validating an argument is cheaper to reason about than silently discarding a bad one" present in the Rust doc comment is dropped. pyo3-stub-gen copies /// comments verbatim and generate_stubs.sh has no em-dash or reflow post-processing step (its only sed rewrites are r#type, r#let, __next__, and the noqa line) — em dashes survive elsewhere in this same file, e.g. line 2405 in the __iter__ docstring just below.

So the next person who runs the documented regeneration flow (AGENTS.md:233-240) will get an unrelated diff on this block. Either regenerate the stub so it matches the macro output, or make the Rust doc comment read as -- / drop the trailing clause there too.

that case.

Without this, a statically chunked space could only be walked at the
template's own chunk size: `chunks_default_task_count` is settable for
adaptive spaces only.
"""

def __len__(self) -> builtins.int: ...
def __getitem__(self, index: builtins.int) -> dict: ...
def __iter__(self) -> StepParameterSpaceIterator:
Expand Down
120 changes: 120 additions & 0 deletions test/openjd/model_v1/test_known_gaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

from __future__ import annotations

from typing import Any

# ── Top-level package no longer leaks typing imports ──
#
# An earlier draft of ``openjd.model._v1`` imported ``Any``,
Expand All @@ -41,3 +43,121 @@ def test_no_internal_imports_leak_at_top_level(name: str) -> None:
import openjd.model._v1 as v1

assert not hasattr(v1, name), f"{name} leaks as a public attribute on openjd.model._v1"


# ── Chunked parameter spaces: two divergences from the v0 reference ──
#
# Found while adding `chunks_task_count_override` to
# `StepParameterSpaceIterator`. Neither is caused by that argument — both
# reproduce without it — so they are recorded here rather than fixed in
# passing.


def _chunked_step(constraint: str) -> Any:
from openjd.model._v1 import create_job, decode_job_template

template = {
"specificationVersion": "jobtemplate-2023-09",
"name": "T",
"extensions": ["TASK_CHUNKING"],
"steps": [
{
"name": "S",
"parameterSpace": {
"taskParameterDefinitions": [
{
"name": "Frame",
"type": "CHUNK[INT]",
"range": "1-10",
"chunks": {"defaultTaskCount": 5, "rangeConstraint": constraint},
}
]
},
"script": {
"actions": {"onRun": {"command": "echo", "args": ["{{Task.Param.Frame}}"]}}
},
}
],
}
job_template = decode_job_template(template=template, supported_extensions=["TASK_CHUNKING"])
return create_job(job_template=job_template, job_parameter_values={}).steps[0]


@pytest.mark.xfail(
reason="v1 derives chunks_parameter_name and chunks_default_task_count from adaptive "
"detection, so both are None for any non-adaptive chunked space. v0 reports them for "
"any chunked space. See openjd-model step_param_space.rs: chunks_param_name and "
"adaptive_chunk_size are both built from adaptive_info.",
strict=True,
)
@pytest.mark.parametrize(
"chunks,override,expected_count",
[
# Statically chunked: no override involved, v0 reports the template's size.
({"defaultTaskCount": 5, "rangeConstraint": "CONTIGUOUS"}, None, 5),
# Statically chunked, re-chunked by the override.
({"defaultTaskCount": 5, "rangeConstraint": "CONTIGUOUS"}, 1, 1),
# Adaptive, turned static by the override. v0 reports the override as the size.
(
{"defaultTaskCount": 5, "targetRuntimeSeconds": 60, "rangeConstraint": "CONTIGUOUS"},
1,
1,
),
],
ids=["static", "static-overridden", "adaptive-overridden"],
)
def test_chunk_metadata_is_reported_for_a_non_adaptive_space(
chunks: dict, override: int | None, expected_count: int
) -> None:
"""v0 returns ``"Frame"`` and the chunk size for each of these. v1 returns ``None``.

Neither value is unknowable — both are in the template, or are the override the caller
just passed — so a consumer inspecting a non-adaptive chunked space through v1 cannot
learn which parameter chunks, or at what size. One root cause, three ways to reach it:
anything that leaves the space non-adaptive drops both getters.
"""
from openjd.model._v1 import create_job, decode_job_template
from openjd.model._v1.job import StepParameterSpaceIterator

template = {
"specificationVersion": "jobtemplate-2023-09",
"name": "T",
"extensions": ["TASK_CHUNKING"],
"steps": [
{
"name": "S",
"parameterSpace": {
"taskParameterDefinitions": [
{"name": "Frame", "type": "CHUNK[INT]", "range": "1-10", "chunks": chunks}
]
},
"script": {
"actions": {"onRun": {"command": "echo", "args": ["{{Task.Param.Frame}}"]}}
},
}
],
}
job_template = decode_job_template(template=template, supported_extensions=["TASK_CHUNKING"])
step = create_job(job_template=job_template, job_parameter_values={}).steps[0]

it = StepParameterSpaceIterator(step=step, chunks_task_count_override=override)
assert it.chunks_adaptive is False
assert it.chunks_parameter_name == "Frame"
assert it.chunks_default_task_count == expected_count


@pytest.mark.xfail(
reason="v1 refuses random access whenever the space needs sequential iteration, and "
"contiguous chunking always does. v0 supports indexing the same space.",
strict=True,
)
def test_a_contiguous_chunked_space_supports_indexing() -> None:
"""v0 answers ``it[0]`` with ``1-5``. v1 raises ``IndexError``.

``len()`` works on this space, so the count is known; only ``get`` declines.
"""
from openjd.model._v1.job import StepParameterSpaceIterator

it = StepParameterSpaceIterator(step=_chunked_step("CONTIGUOUS"))
assert len(it) == 2
assert it[0]["Frame"].value == "1-5"
Loading
Loading