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
1 change: 1 addition & 0 deletions changelog.d/explicit-input-value-state.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `Holder.is_input`, `Simulation.is_input`, and `Simulation.get_value_state` so callers can distinguish explicit inputs (including zeros) from omitted defaults without changing calculation behavior.
39 changes: 39 additions & 0 deletions policyengine_core/holders/holder.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,45 @@ def get_array(self, period: Period, branch_name: str = "default") -> ArrayLike:
if default_value is not None:
return default_value

def is_input(self, period: Period, branch_name: str = "default") -> bool:
"""Return whether this variable was explicitly set as an input.

Distinguishes user-provided values (including explicit zeros) from
values that only exist because a formula ran or a default was applied.
Tracking uses the simulation's existing ``_user_input_keys`` set, which
:meth:`set_input` already maintains; formula cache writes via
:meth:`put_in_cache` are not treated as inputs.

When ``branch_name`` matches the simulation's current branch, inheritance
uses :meth:`Simulation._get_visible_branch_names` (same walk as exportable
input periods). Queries for a different branch only check that branch's
exact key — they do not walk ``parent_branch``, which is only meaningful
relative to the simulation's current branch.

Note: variables with ``definition_period == ETERNITY`` store inputs under
the period key recorded by :meth:`set_input` (often the ETERNITY period
itself). Callers that pass a concrete month/year may miss that key until
a dedicated ETERNITY canonicalization is added; screener monetary inputs
are typically MONTH/YEAR and are unaffected.
"""
simulation = getattr(self, "simulation", None)
if simulation is None:
return False
user_input_keys = getattr(simulation, "_user_input_keys", None)
if not user_input_keys:
return False

period = periods.period(period)
variable_name = self.variable.name

if branch_name == getattr(simulation, "branch_name", "default"):
for visible_branch in simulation._get_visible_branch_names():
if (variable_name, visible_branch, period) in user_input_keys:
return True
return False

return (variable_name, branch_name, period) in user_input_keys

def get_memory_usage(self) -> dict:
"""
Get data about the virtual memory usage of the holder.
Expand Down
32 changes: 32 additions & 0 deletions policyengine_core/simulations/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,38 @@ def get_array(self, variable_name: str, period: Period) -> ArrayLike:
period = periods.period(period)
return self.get_holder(variable_name).get_array(period, self.branch_name)

def is_input(self, variable_name: str, period: Any) -> bool:
"""Return whether ``variable_name`` was explicitly set as an input.

This does not change calculation defaults: omitted numeric inputs still
default to zero (or the variable's ``default_value``) during formulas.
Use this helper when screener-style flows need to tell an intentional
zero apart from a field the user never provided.

:returns: ``True`` if :meth:`Holder.set_input` recorded the key for the
current branch (or an ancestor branch), else ``False``.
"""
if period is not None and not isinstance(period, Period):
period = periods.period(period)
return self.get_holder(variable_name).is_input(period, self.branch_name)

def get_value_state(self, variable_name: str, period: Any) -> str:
"""Return input provenance for ``variable_name`` at ``period``.

Current vocabulary (stable for callers):

- ``"explicit"`` — value was set via :meth:`set_input` / situation inputs
- ``"default"`` — not recorded as a user input (includes omitted fields
that still default to zero in formulas, and formula-filled / cached
values from an input-provenance perspective)

Planned future states (not returned yet): ``"computed"`` for
formula-filled values, and ``"unknown"`` when provenance cannot be
determined. Until those land, treat anything non-explicit as
``"default"``.
"""
return "explicit" if self.is_input(variable_name, period) else "default"

def get_holder(self, variable_name: str) -> Holder:
"""
Get the :obj:`.Holder` associated with the variable ``variable_name`` for the simulation
Expand Down
95 changes: 95 additions & 0 deletions tests/core/test_holders.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,98 @@ def test__given_nan_cache_value__then_put_in_cache_keeps_internal_write_allowed(
salary_holder.put_in_cache(numpy.asarray([numpy.nan]), period)

assert numpy.isnan(salary_holder.get_array(period)).all()


def test_is_input_distinguishes_explicit_zero_from_missing(single):
simulation = single
salary_holder = simulation.person.get_holder("salary")

# Never provided: not an input, value state is default.
assert not salary_holder.is_input(period)
assert not simulation.is_input("salary", period)
assert simulation.get_value_state("salary", period) == "default"

# Explicit zero is still an input (distinct from "missing").
salary_holder.set_input(period, numpy.asarray([0]))
assert salary_holder.is_input(period)
assert simulation.is_input("salary", period)
assert simulation.get_value_state("salary", period) == "explicit"
assert salary_holder.get_array(period) == numpy.asarray([0])


def test_put_in_cache_is_not_user_input(single):
simulation = single
salary_holder = simulation.person.get_holder("salary")

salary_holder.put_in_cache(numpy.asarray([1000.0]), period)

assert salary_holder.get_array(period) is not None
assert not salary_holder.is_input(period)
assert simulation.get_value_state("salary", period) == "default"


def test_situation_inputs_are_marked_explicit(tax_benefit_system):
situation = {
"persons": {
"Alicia": {
"salary": {
"2017-12": 1500,
}
}
},
"households": {
"_": {
"parents": ["Alicia"],
}
},
}
simulation = SimulationBuilder().build_from_entities(tax_benefit_system, situation)
assert simulation.is_input("salary", "2017-12")
assert simulation.get_value_state("salary", "2017-12") == "explicit"
assert not simulation.is_input("salary", "2017-11")


def test_is_input_false_without_simulation_binding(single):
simulation = single
salary_holder = simulation.person.get_holder("salary")
salary_holder.set_input(period, numpy.asarray([0]))
assert salary_holder.is_input(period)

salary_holder.simulation = None
assert not salary_holder.is_input(period)


def test_is_input_false_when_user_input_keys_missing(single):
simulation = single
salary_holder = simulation.person.get_holder("salary")
salary_holder.set_input(period, numpy.asarray([25]))
assert salary_holder.is_input(period)

simulation._user_input_keys = set()
assert not salary_holder.is_input(period)
assert simulation.get_value_state("salary", period) == "default"


def test_is_input_inherits_from_parent_branch(tax_benefit_system):
situation = {
"persons": {
"Alicia": {
"salary": {
"2017-12": 1500,
}
}
},
"households": {
"_": {
"parents": ["Alicia"],
}
},
}
simulation = SimulationBuilder().build_from_entities(tax_benefit_system, situation)
child = simulation.get_branch("reform")
salary_holder = child.person.get_holder("salary")

assert child.branch_name == "reform"
assert salary_holder.is_input("2017-12")
assert child.is_input("salary", "2017-12")
assert child.get_value_state("salary", "2017-12") == "explicit"