Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5b8dd57
feat(workflow-compiling-service): export a workflow as a standalone P…
kz930 Sep 1, 2026
a7558eb
test(workflow-compiling-service): run an operator both ways and compa…
kz930 Sep 1, 2026
cba725f
test(workflow-compiling-service): verify a generated script against t…
kz930 Sep 1, 2026
1cd74b6
feat(workflow-operator): export the base transform operators as Python
kz930 Sep 2, 2026
bc8fc17
ci: give the verify spec a job with an interpreter, and keep it out o…
kz930 Sep 2, 2026
ad5a1e2
Merge remote-tracking branch 'myfork/feat/standalone-verify-harness' …
kz930 Sep 2, 2026
16bb71a
test(verify): pin the no-generator case to an operator that can never…
kz930 Sep 2, 2026
0501644
chore: leave the harness to the change that introduces it
kz930 Sep 2, 2026
fb2fd77
Merge upstream/main
kz930 Sep 2, 2026
c5d3ee6
chore: leave the verification rows to the harness change
kz930 Sep 2, 2026
cb812e5
chore: the schema customizer belongs with the trainers that implement it
kz930 Sep 2, 2026
9bd5e62
docs: say the thing once
kz930 Sep 2, 2026
f2602a4
fix(operator): answer the Regex filter on an empty cell instead of th…
kz930 Sep 3, 2026
c1d0d62
fix(operator): drop the empty cells before the match, not after
kz930 Sep 3, 2026
1d92176
fix(operator): cast to STRING the way the executor does
kz930 Sep 3, 2026
87e6c54
feat(workflow-operator): declare the order flag the sort family overr…
kz930 Sep 4, 2026
8a8a7d0
fix(operator): cast a column the way AttributeTypeUtils does
kz930 Sep 4, 2026
eb2ff3e
Merge remote-tracking branch 'upstream/main' into feat/standalone-bas…
kz930 Sep 4, 2026
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
Expand Up @@ -459,6 +459,18 @@ abstract class LogicalOp extends PortDescriptor with Serializable {

def operatorInfo: OperatorInfo

/**
* Whether the row ORDER of this operator's output is part of its contract.
* Defaults to false: the engine runs operators across parallel workers, so
* for almost every operator the output row order is an implementation-defined
* interleaving. Only operators whose very purpose is to establish an order
* override this, which here is the sort family: Sort, Stable Merge Sort and
* Sort Partitions. Anything comparing two runs of an operator reads it to
* decide whether the rows have to arrive in the same order or only be the
* same rows.
*/
def orderSensitive: Boolean = false

private def getOperatorVersion: String = {
val path = "amber/src/main/scala/"
val operatorPath = path + this.getClass.getPackage.getName.replace(".", "/")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.apache.texera.amber.operator

/** Python definitions shared by several operators' standalone code, emitted
* once per script via [[StandaloneCodeGenerator.standaloneHelpers]].
*/
object StandaloneHelpers {

/**
* A Python transcription of `java.util.Random`, for operators whose executor
* draws from one.
*
* A sampler decides per row whether to keep it, so which rows survive is
* fixed by the exact sequence the generator produces. Seeding Python's
* `random` or numpy's with the engine's seed selects a different set, and
* the script would then report a different sample than the workflow it came
* from. Only the same generator gives the same rows.
*/
val JavaRandom: String =
"""# java.util.Random, transcribed so sampling matches the engine.
|class _TexeraJavaRandom:
| _MASK = (1 << 48) - 1
| _MULTIPLIER = 0x5DEECE66D
| _ADDEND = 0xB
|
| def __init__(self, seed):
| self._seed = (seed ^ self._MULTIPLIER) & self._MASK
|
| def _next(self, bits):
| self._seed = (self._seed * self._MULTIPLIER + self._ADDEND) & self._MASK
| value = self._seed >> (48 - bits)
| return value - (1 << 32) if value >= (1 << 31) else value
|
| def next_double(self):
| return ((self._next(26) << 27) + self._next(27)) * (2.0 ** -53)
|
| def next_int(self, bound):
| if bound & (-bound) == bound:
| return (bound * self._next(31)) >> 31
| while True:
| bits = self._next(31)
| value = bits % bound
| if bits - value + (bound - 1) >= 0:
| return value""".stripMargin

/**
* A Python transcription of `AttributeTypeUtils`, for operators that cast a
* column to a declared type.
*
* Python's own conversions answer differently on the values a spreadsheet
* column actually holds. `bool("false")` is true, because every non-empty
* string is; `int("6.7")` and `float("abc")` raise where a coercing cast
* would have returned 6 and NaN. The engine reads "false" as false, "0" as
* false, and refuses "6.7" as an integer, so the script has to do the same
* rather than hand back a column the workflow never produced.
*
* Refusing is part of the contract: `parseField` raises on a value it cannot
* read, and a script that quietly wrote NaN instead would report an answer
* the run it was exported from never reached.
*/
val AttributeCasts: String =
"""# AttributeTypeUtils, transcribed so a cast answers as the engine does.
|def _texera_cast_boolean(x):
| # toBoolean first, then `toInt == 1`: "0" and "2" are both false.
| if isinstance(x, str):
| text = x.strip()
| lowered = text.lower()
| if lowered == "true":
| return True
| if lowered == "false":
| return False
| return int(text) == 1
| return x != 0
|
|
|def _texera_cast_integral(x):
| # Scala's toInt/toLong take no decimal point, and truncate a Double
| # toward zero.
| if isinstance(x, str):
| return int(x.strip())
| return int(x)
|
|
|def _texera_cast_double(x):
| if isinstance(x, str):
| return float(x.strip())
| return float(x)""".stripMargin
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ import org.apache.texera.amber.core.virtualidentity.{
WorkflowIdentity
}
import org.apache.texera.amber.core.workflow._
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator}
import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeNameList
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper

import javax.validation.constraints.{NotNull, Size}

class AggregateOpDesc extends LogicalOp {
class AggregateOpDesc extends LogicalOp with StandaloneCodeGenerator {

@JsonProperty(value = "aggregations", required = true)
@JsonPropertyDescription("multiple aggregation functions")
@NotNull(message = "aggregation cannot be null")
Expand Down Expand Up @@ -138,4 +140,93 @@ class AggregateOpDesc extends LogicalOp {
inputPorts = List(InputPort()),
outputPorts = List(OutputPort())
)

// The engine aggregates in two phases across partitions; one process needs
// only the one groupby, or a single-row reduction when no key is grouped on.
//
// Must run before `getPhysicalPlan`, which rewrites `aggregations` in place:
// it turns COUNT into SUM for the final phase, and this reads them as written.
override def generateStandaloneCode(): String = {
val keys = Option(groupByKeys).getOrElse(List())
val aggs = Option(aggregations).getOrElse(List())

// Identical helper definition each call — keeps the standalone module
// self-contained without relying on a shared prelude.
val concatHelper =
"""def _texera_agg_concat(series):
| parts = []
| started = False
| for v in series:
| if not started:
| if pd.isna(v):
| continue
| parts.append(str(v))
| started = True
| else:
| parts.append("" if pd.isna(v) else str(v))
| return ",".join(parts)""".stripMargin

if (keys.isEmpty) {
val rowEntries = aggs
.map(agg => s" ${pyStringLiteral(agg.resultAttribute)}: ${aggExprScalar(agg)},")
.mkString("\n")
s"""$concatHelper
|out1df = pd.DataFrame([{
|$rowEntries
|}])""".stripMargin
} else {
val keysLit = keys.map(pyStringLiteral).mkString("[", ", ", "]")
val aggLines = aggs.zipWithIndex
.map {
case (agg, i) =>
s"_texera_agg_s$i = ${aggExprGroupby(agg, "_texera_agg_groups")}"
}
.mkString("\n")
val mergeLines = aggs.indices
.map(i =>
s"""out1df = out1df.merge(_texera_agg_s$i.reset_index(), on=$keysLit, how="left")"""
)
.mkString("\n")
s"""$concatHelper
|_texera_agg_groups = in1df.groupby($keysLit, dropna=False, sort=False)
|out1df = in1df[$keysLit].drop_duplicates().reset_index(drop=True)
|$aggLines
|$mergeLines""".stripMargin
}
}

private def aggExprScalar(agg: AggregationOperation): String = {
val attrLit =
if (agg.attribute == null || agg.attribute.isEmpty) "None"
else pyStringLiteral(agg.attribute)
agg.aggFunction match {
case AggregationFunction.SUM => s"in1df[$attrLit].sum()"
case AggregationFunction.AVERAGE => s"in1df[$attrLit].mean()"
case AggregationFunction.MIN => s"in1df[$attrLit].min()"
case AggregationFunction.MAX => s"in1df[$attrLit].max()"
case AggregationFunction.COUNT =>
if (agg.attribute == null || agg.attribute.isEmpty) "int(len(in1df))"
else s"int(in1df[$attrLit].count())"
case AggregationFunction.CONCAT => s"_texera_agg_concat(in1df[$attrLit])"
}
}

private def aggExprGroupby(agg: AggregationOperation, groups: String): String = {
val attrLit =
if (agg.attribute == null || agg.attribute.isEmpty) "None"
else pyStringLiteral(agg.attribute)
val resultLit = pyStringLiteral(agg.resultAttribute)
agg.aggFunction match {
case AggregationFunction.SUM => s"$groups[$attrLit].sum().rename($resultLit)"
case AggregationFunction.AVERAGE => s"$groups[$attrLit].mean().rename($resultLit)"
case AggregationFunction.MIN => s"$groups[$attrLit].min().rename($resultLit)"
case AggregationFunction.MAX => s"$groups[$attrLit].max().rename($resultLit)"
case AggregationFunction.COUNT =>
if (agg.attribute == null || agg.attribute.isEmpty)
s"$groups.size().rename($resultLit)"
else s"$groups[$attrLit].count().rename($resultLit)"
case AggregationFunction.CONCAT =>
s"$groups[$attrLit].apply(_texera_agg_concat).rename($resultLit)"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import org.apache.texera.amber.core.executor.OpExecWithClassName
import org.apache.texera.amber.core.tuple.{Attribute, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow._
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.{LogicalOp, StandaloneCodeGenerator}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}

class CartesianProductOpDesc extends LogicalOp {
class CartesianProductOpDesc extends LogicalOp with StandaloneCodeGenerator {

override def getPhysicalOp(
workflowId: WorkflowIdentity,
executionId: ExecutionIdentity
Expand Down Expand Up @@ -103,4 +104,27 @@ class CartesianProductOpDesc extends LogicalOp {
),
outputPorts = List(OutputPort())
)

// Schema mirrors SchemaPropagationFunc: left columns kept as-is, each right
// column renamed by repeatedly appending "#@1" while the candidate name
// collides with any left column OR any other right column's ORIGINAL name.
// The renamed-name table is recomputed at runtime from the actual DataFrame
// columns. Known divergence: row order — pandas cross-merge varies right
// fastest (L1R1, L1R2, L2R1, L2R2); the JVM op buffers left and emits per
// arriving right tuple (L1R1, L2R1, L1R2, L2R2). Cartesian product is set-
// semantically order-agnostic, so this is acceptable.
override def generateStandaloneCode(): String = {
"""_left_cols = list(in1df.columns)
|_right_cols = list(in2df.columns)
|_left_set = set(_left_cols)
|_right_set = set(_right_cols)
|_rename = {}
|for _col in _right_cols:
| _new = _col
| _others = _right_set - {_col}
| while _new in _left_set or _new in _others:
| _new = _new + "#@1"
| _rename[_col] = _new
|out1df = in1df.merge(in2df.rename(columns=_rename), how="cross").reset_index(drop=True)""".stripMargin
}
}
Loading
Loading