Skip to content

feat: bind number and true/false loop variables in generated Python operators - #9359

Open
aglinxinyuan wants to merge 70 commits into
apache:mainfrom
aglinxinyuan:feat/generated-op-number-refs
Open

aglinxinyuan wants to merge 70 commits into
apache:mainfrom
aglinxinyuan:feat/generated-op-number-refs

Conversation

@aglinxinyuan

@aglinxinyuan aglinxinyuan commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What changes were proposed in this PR?

Inside a control block (Loop Start ... Loop End), a number or true/false property of an operator whose Scala descriptor generates Python, such as Radar Chart's Fill Opacity or Radar Plot's Show Legend, can now hold a loop-variable reference such as $o. The descriptor writes such a value into the code when the workflow is compiled, before the loop runs. To find where it lands, the code is generated from copies of the descriptor with probe values in the reference's place. Where the two codes differ by exactly the value, and nowhere else, the code reads it from the iteration's state instead:

Loop Start (o = 0.25) -> Radar Chart (fill opacity = $o) -> Loop End

generated with 0.25:  opacity=0.25
generated with 0.75:  opacity=0.75        (the codes differ here, and only here)
inside a block:       opacity=self.loop_variable_value('o', 'number')
  • Probes. 3 and 5, then 70 and 90 for an integer; 0.25 and 0.75, then 2.5 and 7.5 for a number; True and False. When the descriptor rejects every pair, as Figure Factory Table does below a Row Height of 30, the code is generated from the property's default and the value next to it.
  • What stays a compile error. The value must also land as it is at 0, 1, -1, 1000000 and the other probes. The reference stays a compile error that names the property and why for a value the descriptor checks or adjusts (Histogram2D's bins, Hugging Face's token limit and temperature, Figure Factory Table's Row Height and Font Size), one that lands in a text, a comment or a name, one the code computes with, never holds, or reads as the operator starts (in open or __init__, before any state message can arrive), and one that decides the output schema. A value that fails a check and also decides the schema gets the check's reason. No error quotes the descriptor's own message about a probe value the user never typed.
  • One probing per compile. The compile builds each generated operator's LoopVariableBinding at most once, probing its number and true/false references then, and the physical operator runs the binding's code. For a generated operator, WorkflowCompiler.unbindableStateReferences only reports the reasons that binding gives, for number, true/false and text references alike. The schema reason waits until every input has a schema, as the schema propagation does.
  • Reading the value. Operator.loop_variable_value(name, kind) reads the variable's text as loop_variable_text spells it, from the values the operator fixed when its first tuple arrived, and converts it as a JVM operator's property takes it (StateReferenceBinding.coerce, then Jackson). The text is trimmed as String.trim trims it. An integer is read as new BigDecimal(text).longValueExact() reads it: within 32 bits for an int or Integer property ('integer'), within 64 for a long one ('long'). A number is read as Double.parseDouble reads it (2.5d and 0x1p3, but not inf, nan, 1_000 or fullwidth digits), and a boolean as true or false in any case, never 1 or 0.
  • What must have arrived by then. As for text references, the operator fails at its first tuple or input end, naming each number or true/false variable its code reads that no state message carried, that got two different values from the same loop, or whose value its property cannot take, whether or not the code would read it then. A variable with two values is named once, however many properties read it, as a JVM operator fails for it once.

Outside every block nothing changes. The stateReferences sidecar from #8639, which maps each property that holds a reference to its variable, is empty there in any workflow that compiles, so the code and the schema are the same as on main.

This builds on #8671; #8642 is on main.

Any related issues, documentation, discussions?

Closes #9358.

How was this PR tested?

The test commit, 959939d, fails without the feat commit, 9e841f2: the Scala specs do not compile, the new Python tests fail with AttributeError or on the values they read, and the two new end-to-end cases fail to compile their workflow.

  • PythonOperatorDescriptorSpec: Radar Chart's opacity read as a number, next to a text reference; Histogram2D's checked bins and Hugging Face's clamped values stay compile errors, and so does a sklearn boolean that picks the code's shape; Figure Factory Table's Row Height, which rejects every probe, and Font Size reported as checked, with no probe value in the code; a generation that the descriptor's own values fail embeds that failure; nulls the descriptor holds survive the copies; every descriptor the frontend can send, with each top-level number and true/false property referring to a loop variable, embeds no failure but its own.
  • WorkflowCompilerSpec, with test-only generated operators: an integer, a number and a Python boolean read from the loop state; an int property's reference read as 'integer' and a long one's as 'long'; a value the descriptor rejects at a probe, at an edge value or at every probe, adjusts, or computes with; a value in a text, a comment or next to a name; a value the code does not hold, or reads in open; two values that land in one place; the next probe pair; the schema from a probe, and a value that decides it, reported in one error with the other references, only once every input has a schema, and after the check's reason when the value also fails a check; a text the operator checks, judged on the probing's copy where a number's placeholder fails the schema; the descriptor probed once per compile.
  • test_operator.py, test_executor_manager.py: loop_variable_value for every kind and scalar type, against tables generated on JDK 17 through the compiled State.fromJson and StateReferenceBinding.coerce, and through a Limit's int property for the 32-bit bound. The tables cover trimming, signs, exponents of any length, hexadecimal numerals with letter digits, + exponents and suffixes, NaN and Infinity spellings, other scripts' digits, numerals that start or end with a point, the 32-bit and 64-bit limits, and true/false in any case. Also: the values fixed at the first tuple, kept when a later state arrives; the failure for a required value no state carried, one with two different values from the same loop (named once, however many properties read it, and before any kind check), or one its property cannot take, the texts named first; a variable with two values that the code does not read, which binds and fails only when read; errors before any state, for a missing name, for a non-scalar and for an unknown kind; the values the executor manager notes from the generated code; snippets shaped like the generated code, run against a real operator.
  • LoopIntegrationSpec, end to end: sales CSV -> Loop Start (o = 0.25, table) -> Radar Chart (fill opacity = $o) -> Loop End (o += 0.25 while o <= 0.75) draws three charts whose traces have opacity 0.25, 0.5 and 0.75. Radar Plot with Show Legend = $legend (True, then False), over the first i + 1 rows, shows the legend on the first iteration's plot only; each plot is matched to its iteration by its trace count.
sbt "WorkflowCompiler/test"                                       # 135 passed
sbt "WorkflowOperator/testOnly org.apache.texera.amber.operator.PythonOperatorDescriptorSpec org.apache.texera.amber.util.PythonCodeRawInvalidTextSpec org.apache.texera.amber.operator.loop.* org.apache.texera.amber.operator.metadata.* org.apache.texera.amber.operator.machineLearning.* org.apache.texera.amber.operator.sklearn.* org.apache.texera.amber.operator.visualization.* org.apache.texera.amber.operator.huggingFace.*"
# 1492 passed, 2 pending; on Windows only, UrlVizOpDescSpec's export check fails on CRLF, as on main
sbt "PyBuilder/test"                                              # 192 passed
sbt "WorkflowCompilingService/test"                               # 70 passed
sbt "WorkflowCore/testOnly org.apache.texera.amber.core.state.* org.apache.texera.amber.core.executor.*"   # 76 passed
sbt "WorkflowExecutionService/testOnly org.apache.texera.amber.engine.e2e.LoopIntegrationSpec -- -z Python"
# 5 passed: Radar Chart and Radar Plot above, #8671's SVR trainer and #8642's two Python UDF cases
sbt "WorkflowCompiler/scalafmtCheckAll" "WorkflowCompiler/scalafixAll --check"
# clean; likewise WorkflowOperator and WorkflowExecutionService
cd amber && pytest src/test/python/core/models src/test/python/core/runnables \
  src/test/python/core/architecture/managers/test_state_processing_manager.py \
  src/test/python/core/architecture/managers/test_executor_manager.py
# 817 passed, 9 skipped, 1 xfailed; on Windows only, test_tuple.py's test_hash fails, as on main
ruff check src/main/python src/test/python && ruff format --check src/main/python src/test/python
# clean

Was this PR authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

🤖 Generated with Claude Code

Inside a control block the iteration's loop variables reach every operator
as a state message. The Python worker now stores that message on
`Operator.loop_state` right before `process_state` runs -- in
DataProcessor.process_state and in the LoopEnd's deferred consume, which
calls the operator directly -- so the callback and every call after it can
consult the current iteration's variables. The default callback is
unchanged and still forwards the state.
A property of an operator inside a loop block (LoopStart ... LoopEnd) may
name a loop variable as `$K`. The frontend keeps sending the literal
string whatever the property's type; the backend binds it in three steps.

Parse. LogicalOp is now a StateReferencing descriptor, with a
`stateReferences` sidecar (JSON pointer -> variable name) that the
property panel never shows. StateReferenceModule, registered on
JSONUtils.objectMapper and on the workflow-compiling-service mapper, adds
a Jackson problem handler: when a whole-string `$name` cannot be converted
to an integral, floating-point or boolean property, it returns the
placeholder 0 / 0.0 / false and records the value's pointer, relative to
the operator, in the operator's sidecar. A thin wrapper around each
concrete StateReferencing deserializer opens that recording frame. An
object that a polymorphic type deserializer hands over mid-way is first
copied into a buffer of its own, because Jackson replays the fields before
a late type id from a buffer whose parse context copies the first field's
value, not the object. String properties keep the literal, and any other
target (an enum, ...) keeps Jackson's ordinary error.

Compile. WorkflowCompiler works out which operators lie inside a loop
block (LoopBlockMembership, the frontend's block walk) and makes the
sidecar final before the physical plan serializes it into the executor's
descString. Inside a block it adds every whole-string `$name` of the
operator's JSON. Outside every block a string such as "$AAPL" stays the
literal it is on main, and a typed placeholder is a compile error naming
the property and the variable. Inside a block, an operator whose executor
runs generated code, or with an input fed from outside the block, is a
compile error too.

Worker. ExecFactory hands out a LateBoundExecutor when the descString's
sidecar is not empty. It keeps and forwards each state message, and binds
when the real executor is first needed, at the first tuple or finishing
port: the channel has delivered its state messages by then, so their
values are merged with the last one winning, and an inner loop's variable
shadows an outer one of the same name. It patches the placeholders,
coerced to their JSON type (a string property takes only a string, a
number or a boolean), builds the real executor through the normal factory
path, opens it and replays the kept state messages into it in order, so an
executor that acts on them (If) behaves as without references. Every later
call goes to that executor, which is never rebound. A variable still
missing then is an error naming it.

Closes apache#8635
The attribute the runtime registers on every operator right before
process_state is now `state` instead of `loop_state`.

LoopStartOperator and LoopEndOperator already used `self.state` for their
own loop variables, so registering the incoming message there would have
merged it into itself (LoopStart) and made the back-edge write the message
instead of the updated variables (LoopEnd). Their own field is renamed to
`variables`, and the back-edge in MainLoop._jump_to_loop_start now writes
`executor.variables`. Loop operator behavior is unchanged.

Two new tests pin the split: a LoopStart run through
DataProcessor.process_state keeps its variables apart from the registered
state, and the LoopEnd back-edge writes the updated variables rather than
the registered state.
The attribute the runtime registers before process_state is now `state`
(apache#8642), so the method that registers it follows: Operator.register_loop_state
is now Operator.register_state. DataProcessor.process_state and the deferred
consume in MainLoop call it under the new name, and its docstring and the
tests say `state` / `register_state` throughout. The loop-variable reads,
loop_variable_text and loop_variable_value, and their _loop_variables are
unchanged.
…nding gaps

SplitOpExec.open() resets its generator again, as before the simplification,
so a reopened executor repeats its seeded split; the reseed test is back.

New tests, each checked against a mutant that the old suites let through:
- a reference on an operator fed by another body operator compiles
  (LoopStart -> Projection -> Limit($n) -> LoopEnd);
- the compiling service's Dropwizard mapper parses a typed "$n";
- a control operator is not inside its own block when an outer block is
  left open, in both directions;
- ExecFactory finds a setting declared only in a superclass, and tells two
  equal settings apart by identity;
- a sidecar pointer that is not a JSON pointer is reported at binding.

The Filter($i) test runs the compiled executor again.
…ock around it

The outside-input check counted any operator inside some block as carrying the state, so an
inner block's operator could read first from the outer body or a sibling block, whose tuples
come before the inner loop's state. LogicalPlan.enclosingLoopStarts gives the LoopStarts
around each operator, checked against the panel's walk, and a link carries the state only when
its source is inside all of the operator's blocks or opens the innermost. Also pins an input
with one link from inside the block and one from outside.
- LogicalPlan: derive the operators inside loop blocks from
  enclosingLoopStarts, so the block walk runs once and is cached.
- WorkflowCompiler: normalizeStateReferences reads membership from the
  plan instead of a separate parameter.
- OperatorExecutor: two weak maps (states, bindings) replace the
  Registration holder; StateReferenceBinding.bind() replaces `unbound`.
- Shorter docs on ExecFactory and JSONUtils.registerWorkflowModules.
- Tests: set-only port of the panel walk, merged overlapping compiler,
  binding and mapper cases, EndChannelHandlerSpec reuses the
  DataProcessorSpec fixture, and the single-loop Filter($i) run is
  dropped (the branching run covers it). Pin a variable written into
  every property that names it, and a refused message writing and
  registering nothing.
…-loop-refs

# Conflicts:
#	amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala
…fs-backend

# Conflicts:
#	amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala
…perators

Inside a control block, a number or true/false property of an operator
whose Scala descriptor generates Python, such as Radar Chart's Fill
Opacity or Radar Plot's Show Legend, is written into the code before
the loop runs, so the compiler rejects a reference such as $o there
today.

These tests pin how such a reference is read when the code runs:

- PythonOperatorDescriptorSpec and WorkflowCompilerSpec: the code is
  generated from probe values in the reference's place, and where the
  value lands as it is, the code calls
  self.loop_variable_value(name, kind) instead: 'integer' for an int
  property, 'long' for a long one. A value the descriptor checks or
  adjusts (Histogram2D's bins, Hugging Face's limits, Figure Factory
  Table's Row Height and Font Size), one inside a text, a comment or a
  name, one the code computes with, never holds or reads when the
  operator starts, and one that decides the output schema stay compile
  errors that name the property and why, never a probe value the user
  did not type. The compile probes the code once and decides every
  reason from that binding: the code's reason before the schema's, and
  a schema reason only once every input has a schema.
- test_operator.py: loop_variable_value reads the values bound at the
  operator's first tuple and converts them as StateReferenceBinding
  does: trimmed as String.trim, then new BigDecimal(text)
  .longValueExact() (within 32 bits for an int property, as Jackson
  writes it), Double.parseDouble, or true/false by equalsIgnoreCase.
  The parity tables are generated on JDK 17 through the compiled
  State.fromJson and StateReferenceBinding.coerce, and through a Limit's
  int property for the 32-bit bound. Binding fails for a required value
  its property cannot take, as the JVM's binding does.
- test_executor_manager.py: binding requires the values the generated
  code reads, by name and kind.
- LoopIntegrationSpec: Radar Chart's opacity and Radar Plot's legend
  follow the loop variable on every iteration; each legend is matched
  to its iteration by the plot's trace count.

Before the next commit the Scala specs do not compile (there is no
loopVariableBinding), the new Python tests fail with AttributeError or
on the values they read, and the two new LoopIntegrationSpec cases fail
to compile their workflow.
…perators

A number or true/false property of an operator whose Scala descriptor
generates Python can now refer to a loop variable inside a control
block. The code is generated before the loop runs, so
PythonOperatorDescriptor.loopVariableBinding generates it from copies of
the descriptor with probe values in the reference's place: 3 and 5, then
70 and 90 for an integer; 0.25 and 0.75, then 2.5 and 7.5 for a number;
True and False. Where the two codes differ by exactly the two values,
and nowhere else, the code calls self.loop_variable_value(name, kind)
there instead: 'integer' for an int property, 'long' for one that takes
a whole number past 32 bits. The value must also land as it is at 0, 1,
-1, 1000000 and the other probes. When no probe pair generates the
code, the properties' defaults and the values next to them are tried,
so a descriptor's own check never reports a probe value. A value the
descriptor checks or adjusts, one that lands in a text, a comment or a
name, one the code computes with, never holds or reads when the
operator starts (in open or __init__, before any state message), and
one that decides the output schema leave the reference a compile error
that names the property and why.

WorkflowCompiler probes the code once per compile: the physical
operator runs that binding's code, and unbindableStateReferences
decides every reason from the same binding, the code's before the
schema's, and the schema one only once every input has a schema, as
the schema propagation does.

Operator.loop_variable_value reads the variable's text as
loop_variable_text spells it, from the values bound at the operator's
first tuple, and converts it as a JVM operator's property binds it
(StateReferenceBinding.coerce): trimmed as String.trim, an integer as
new BigDecimal(text).longValueExact() reads it, and within 32 bits for
an int property, as Jackson writes it there; a number as
Double.parseDouble does (2.5d and 0x1p3, but not inf, nan, 1_000 or
fullwidth digits), and a boolean as true or false in any case. A whole
double past 10^16 reads as the whole number its fewest digits spell, as
JDK 19 and later bind it; JDK 17 spells it with other digits
(JDK-4511638). An integer past 64 bits is refused, as
loop_variable_text refuses it.

The executor manager also hands each Python operator the values its
code reads through loop_variable_value, so binding fails, naming each,
for a required value no state message carried or one its property
cannot take, as the JVM's binding does, whether or not the code reads
the value then.
Copilot AI balanced review requested due to automatic review settings November 7, 2026 12:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Automated Reviewer Suggestions

Based on the git blame history of the changed files, we recommend the following reviewers:

  • Contributors with relevant context: @kz930, @carloea2, @sshiv012
    You can notify them by mentioning @kz930, @carloea2, @sshiv012 in a comment.

@codecov-commenter

codecov-commenter commented Nov 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.11312% with 110 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.63%. Comparing base (00940a3) to head (e33b951).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...he/texera/amber/operator/LoopVariableBinding.scala 59.77% 74 Missing and 31 partials ⚠️
...ache/texera/common/compiler/WorkflowCompiler.scala 78.94% 0 Missing and 4 partials ⚠️
...texera/amber/pybuilder/PythonTemplateBuilder.scala 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #9359      +/-   ##
============================================
- Coverage     91.75%   91.63%   -0.12%     
- Complexity     5022     5043      +21     
============================================
  Files          1281     1282       +1     
  Lines         56777    57208     +431     
  Branches       7454     7543      +89     
============================================
+ Hits          52096    52423     +327     
- Misses         2802     2874      +72     
- Partials       1879     1911      +32     
Flag Coverage Δ *Carryforward flag
access-control-service 77.44% <ø> (ø)
agent-service 99.53% <ø> (ø) Carriedforward from cd9113d
amber 85.44% <62.83%> (-0.32%) ⬇️
computing-unit-managing-service 64.05% <ø> (ø)
config-service 87.73% <ø> (ø)
file-service 82.31% <ø> (ø)
frontend 96.38% <ø> (+<0.01%) ⬆️ Carriedforward from cd9113d
notebook-migration-service 83.77% <ø> (ø)
pyamber 98.54% <100.00%> (+0.04%) ⬆️
workflow-compiling-service 78.30% <ø> (ø)

*This pull request uses carry forward flags. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

🟢 3 better · 🔴 2 worse · ⚪ 10 noise (<±5%) · 0 without baseline

Compared against main 00940a3 benchmarked on this same runner, so the delta is largely free of cross-runner hardware noise. The "7d avg" column still reflects the gh-pages dashboard. Treat <±5% as noise unless repeated.

Dashboard · Run

config throughput MB/s latency max Δ latest / 7d
🟢 bs=10 sw=10 sl=64 532 0.325 18,578/26,789/26,789 us 🟢 -8.6% / 🔴 +84.4%
🔴 bs=100 sw=10 sl=64 1,155 0.705 83,188/115,473/115,473 us 🔴 -6.0% / 🔴 +20.0%
⚪ bs=1000 sw=10 sl=64 1,464 0.893 677,213/733,822/733,822 us ⚪ within ±5% / 🟢 -22.6%
Baseline details

Latest main 00940a3 from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 532 tuples/sec 493 tuples/sec 901.09 tuples/sec +7.9% -41.0%
bs=10 sw=10 sl=64 MB/s 0.325 MB/s 0.301 MB/s 0.55 MB/s +8.0% -40.9%
bs=10 sw=10 sl=64 p50 18,578 us 20,335 us 11,350 us -8.6% +63.7%
bs=10 sw=10 sl=64 p95 26,789 us 27,671 us 14,525 us -3.2% +84.4%
bs=10 sw=10 sl=64 p99 26,789 us 27,671 us 17,637 us -3.2% +51.9%
bs=100 sw=10 sl=64 throughput 1,155 tuples/sec 1,228 tuples/sec 1,205 tuples/sec -5.9% -4.1%
bs=100 sw=10 sl=64 MB/s 0.705 MB/s 0.75 MB/s 0.735 MB/s -6.0% -4.1%
bs=100 sw=10 sl=64 p50 83,188 us 81,673 us 88,882 us +1.9% -6.4%
bs=100 sw=10 sl=64 p95 115,473 us 114,192 us 96,205 us +1.1% +20.0%
bs=100 sw=10 sl=64 p99 115,473 us 114,192 us 120,754 us +1.1% -4.4%
bs=1000 sw=10 sl=64 throughput 1,464 tuples/sec 1,471 tuples/sec 1,245 tuples/sec -0.5% +17.6%
bs=1000 sw=10 sl=64 MB/s 0.893 MB/s 0.898 MB/s 0.76 MB/s -0.6% +17.6%
bs=1000 sw=10 sl=64 p50 677,213 us 675,969 us 862,870 us +0.2% -21.5%
bs=1000 sw=10 sl=64 p95 733,822 us 755,174 us 905,485 us -2.8% -19.0%
bs=1000 sw=10 sl=64 p99 733,822 us 755,174 us 948,588 us -2.8% -22.6%
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,375.61,200,128000,532,0.325,18578.01,26788.83,26788.83
1,100,10,64,20,1731.07,2000,1280000,1155,0.705,83188.06,115473.06,115473.06
2,1000,10,64,20,13663.27,20000,12800000,1464,0.893,677213.46,733822.47,733822.47

…o values

Since main's apache#8642, reading a name that got two different values from one
depth fails. bind_loop_variables copies loop_variables with dict(), which reads
every name, so a generated operator fails at its first tuple, or when an input
ends, as soon as any name has two values, even one no property refers to. A JVM
operator fails only for a name its setting refers to. main's
test_main_loop_thread_can_process_state (four values of one name, then
EndChannel) fails the same way.

Pin that binding fails only for a required name, listed with the other
problems, and that the bound values keep the two values: reading that name
fails, the other names read, and a deeper value before binding replaces the
two.
… values

bind_loop_variables now copies the values and the names that got two different
values, as loop_variables holds them, instead of reading every name through
dict(). Binding fails for a required name with two values, listed with the
other problems, and reading such a name from the bound values fails with the
same error, while the other names read.
loop_variable_value reads a variable's text through _loop_variable_text,
and binding checks each required value the same way, so since
feat/generated-op-loop-refs merged in, both follow its rule for a name
that got two different values from one depth: binding fails only for a
required name, listed with the other problems in order, and the bound
values keep the two values.

Pin it for numeric and boolean properties: an operator whose code reads
only n binds and converts n while i has two values; a required i with
two values fails binding with StateReferenceBinding's message whether or
not the values fit the property's kind, as a JVM operator fails before
it converts either; reading i as any kind fails before and after
binding, and still after a deeper i arrives; the other names convert as
before; a deeper value before binding replaces the two.

The merge commit already passes these tests, so no source change
follows.
Binding checks every read of a loop variable the generated code makes:
once as a text, and once for each kind it is read as. A name that got
two different values from one depth fails each of those reads with the
same error, so code that reads it as an integer, a number and a text
lists StateReferenceBinding's message three times. A JVM operator
throws that message once for the name (StateReferenceBinding.write goes
over each referenced name once).

Pin that binding names the two values once, where the name is first
read: for code that reads n three ways, for a text and several kinds,
and beside a missing and a refused value, which keep their place. A
value that two kinds refuse is still named once for each kind.
bind_loop_variables now names a required loop variable that got two
different values once, where the code first reads it: a value read of
that name adds StateReferenceBinding's message only when no text or
earlier kind has added it. Every read is still checked, and missing and
refused values are named as before.
… the loop runs

Inside a loop block, SVM Regressor took a Ground Truth Attribute Column
"$y" beside the Selected Feature "Unit Price". Its check that the ground
truth is no feature runs in getOutputSchemas, before the loop runs, so
it saw "$y", and when y was "Unit Price" the run fitted Unit Price on
itself, which the operator refuses outside a loop. Sklearn Prediction
failed a ground truth "$g" with "Ground Truth column '$g' is not in the
input table", and Machine Learning Scorer an Actual Value "$a" the same
way, neither saying that a loop variable cannot go there.

These cases pin the reason "it checks the text before the loop runs"
for the three, a feature "$f", a classic trainer's target with Count
Vectorizer on and both of the Scorer's columns included, and "the text
decides its output schema" for a test-only operator that types its
output column like the input column its text names. The same operator
pins a check in its code generation against another of its texts, one
naming an input column and one naming none; a check against the empty
text, which no copy tries, so the text stays bound; and a null setting,
which the copies keep rather than take the property's default. As the
schema propagation, the check waits until every input has a schema.
…the loop runs

PythonOperatorDescriptor.textsReadBeforeTheLoop tells which of a
generated operator's text references it checks, or computes its output
schemas from, before the loop runs. It builds copies of the descriptor
that hold another text in the reference's place: the first column of
each type on each input, and each other non-empty text among its
settings, which a check commonly compares the text to. Where only one
of a copy and the copy it is compared with computes its output schemas,
or the copy fails to generate its code where the other does not, the
operator checks the text; where both compute them and they differ, the
text decides them. The copy compared with holds every reference's
"$name", unless its schemas fail; then it is the first copy that
computes them with such texts in the references' places, so that one
reference that fails a check cannot hide another's.

WorkflowCompiler asks it once every input has a schema, for the texts
the generated code would otherwise bind, and refuses them with "it
checks the text before the loop runs" or "the text decides its output
schema", much as apache#9359 refuses a number it checks or computes its
schema from.

Inside a loop this refuses the ground truth and Selected Features of
the KNN Classifier, KNN Regressor, SVC and SVR trainers, and the Target
and Text Attribute of the classic sklearn trainers with Count Vectorizer
on, which each check against the other. Sklearn Prediction's ground
truth and the Machine Learning Scorer's columns, which failed before
with "... is not in the input table", now get this reason. Sweeping
every generated operator, one text property at a time, finds no other
text it refuses.
On this branch a generated operator's code and output schemas come from
the binding's baseline copy, which holds a probe value for each numeric
or boolean reference, while the descriptor holds its placeholder (0,
0.0 or false). apache#8671's check of the texts runs on the descriptor, so a
placeholder that fails the operator's schema makes every copy fail
alike and hides the check of a text. This case pins it with a test-only
operator whose schema requires its ratio, "$r", to be positive and its
column, "$c", not to be "total": "$c" is refused, and with nothing to
avoid both references are read from the loop state.
LoopVariableBinding now holds the copy its code and output schemas come
from, the descriptor itself or the probing's baseline, in place of that
copy's schema function, and asks that copy which text references it
checks or computes its schemas from (textsReadBeforeTheLoop). The
compiler asks the binding rather than the descriptor, whose
placeholders for the numeric and boolean references may fail a check
whatever the text is.

@Xiao-zhen-Liu Xiao-zhen-Liu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I ran the PR's Python tests (495 passed) and set every top-level number and true/false property of the generated operators I could configure to $v inside a loop: Time Series, Bar Chart, Word Cloud, Scatter Plot opacity and Dumbbell bind, and the others are refused at compile time. One structural ask: the probing grows the base that every generated operator's Scala descriptor extends from 312 to 773 lines. Could it live in its own file, with the base only calling it?

…ding

Move the probing and the judging of loop-variable references out of
PythonOperatorDescriptor (the base trait of generated operators) and its
companion object, and the text reasons that WorkflowCompiler worked out,
into their own file, LoopVariableBinding.scala. The class
LoopVariableBinding, which was nested in PythonOperatorDescriptor's
companion and is now top-level, and its new companion object hold the
probing that was in PythonOperatorDescriptor (the probe pairs, edge
values, baseline and variant copies, finding where a value lands, the
probing helpers and the JSON copy helpers), the check of whether code
runs when the operator starts (runsAtStartup), the text check that was
the trait's textsReadBeforeTheLoop, and the reasons the compiler used to
work out for text references (the code embeds the text, does not read
it, reads it when the operator starts, or the text names an output
column), with their gating and order. PythonOperatorDescriptor now only
calls LoopVariableBinding.of(this) and builds its physical operator from
the binding; its companion keeps only loopVariableLookup and
loopVariableValueLookup, the calls through which generated code reads a
loop variable.

The binding now decides the number, true/false and text reasons itself:
the number and true/false reasons other than the schema one while it
generates the code (LoopVariableBinding.of), and the schema and text
reasons in reasons, given the input schemas when every input has one,
which returns them all in the order the error prints them. The columns
an operator adds come from the baseline's output schemas, computed once
in reasons for both the schema check and the column check. The physical
operator propagates its schemas with outputSchemas, which throws the
schema reason too, from the same schema check, decidedBy. One helper,
readsAtStartup, checks whether code reads a value or a text when the
operator starts, and each pair of messages that differ only by "text" or
"value" comes from a builder that takes the noun (unread, readAtStartup,
decidesOutputSchema). For a generated operator,
WorkflowCompiler.unbindableStateReferences only reports the binding's
reasons; any other operator that runs code still gets the fixed-code
reason there.

getPhysicalPlan and getPhysicalOp from a binding are now final, so the
physical operator always runs the code the binding judged.
LoopVariableBinding.of and outputSchemas, which only the operator package
calls, are private[operator]; unboundBySchema, textsReadBeforeTheLoop,
runsAtStartup and the reason strings are private. WorkflowCompilerSpec's
note on its test-only operators now names LoopVariableBinding as what
parses their copies.

Every compile error message is unchanged, and so is the order of its
parts.

Line counts: PythonOperatorDescriptor.scala 773 -> 134,
WorkflowCompiler.scala 549 -> 459, new LoopVariableBinding.scala 838.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bind loop variables in the number and true/false properties of generated Python operators

4 participants