Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "hello_rust"
version = "0.1.4"
version = "0.1.5"
edition = "2024"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
Expand Down
4 changes: 2 additions & 2 deletions recipe/meta.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% set name = "hello-rust" %}
{% set version = "0.1.4" %}
{% set version = "0.1.5" %}

package:
name: {{ name|lower }}
Expand All @@ -9,7 +9,7 @@ source:
path: ..

build:
number: 1
number: 0
script: {{ PYTHON }} -m pip install . -vv

requirements:
Expand Down
67 changes: 44 additions & 23 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,21 @@ fn choose_candidate_by_plan(
Ok(best_idx)
}

fn has_blocker_on_node_after_target(
fn earliest_blocker_on_node_after_target(
py: Python<'_>,
rows: &[Py<PyDict>],
self_index_all: &HashMap<String, Vec<usize>>,
node_key: &str,
ref_target: &Bound<'_, PyAny>,
target_col: &str,
block_key: &HashMap<String, String>,
) -> PyResult<bool> {
) -> PyResult<Option<Py<PyAny>>> {
let Some(indices) = self_index_all.get(node_key) else {
return Ok(false);
return Ok(None);
};

let mut earliest: Option<Py<PyAny>> = None;

for idx in indices {
let row = rows[*idx].bind(py);
if !matches_kv(&row, block_key)? {
Expand All @@ -196,12 +198,20 @@ fn has_blocker_on_node_after_target(
continue;
}

if compare_bool(&block_target, ref_target, CompareOp::Gt) {
return Ok(true);
if !compare_bool(&block_target, ref_target, CompareOp::Gt) {
continue;
}

let should_take = match &earliest {
None => true,
Some(current) => compare_bool(&block_target, current.bind(py), CompareOp::Lt),
};
if should_take {
earliest = Some(block_target.unbind());
}
}

Ok(false)
Ok(earliest)
}

/// For each row, walk through parent links (single-path DFS over ancestor chain) until a parent
Expand All @@ -216,12 +226,12 @@ fn has_blocker_on_node_after_target(
///
/// block_key:
/// - Optional key/value matcher applied at node level.
/// - For each active row, blocker checking starts on the row's own self node first.
/// - If not blocked on self node, blocker checking continues on ancestor parent nodes during
/// traversal.
/// - If any checked node has a blocker event with target_col > current reference target,
/// traversal is terminated.
/// - In that case the original target value remains unchanged.
/// - For each active row, blocker search starts on self node and continues on traversed
/// parent nodes.
/// - Blockers are filtered by target_col > anchor target, then the earliest blocker among
/// visited nodes is tracked.
/// - Before accepting a matched target candidate, compare candidate target_col vs blocker:
/// if target is earlier than blocker, keep target; otherwise break and keep original value.
fn propagate_target_rows(
py: Python<'_>,
rows: &[Py<PyDict>],
Expand Down Expand Up @@ -264,9 +274,10 @@ fn propagate_target_rows(
}
let anchor_target = start_target.clone();
let start_self = composite_key(&start_row, self_cols)?;
let mut earliest_blocker: Option<Py<PyAny>> = None;

if let Some(kv) = block_key {
if has_blocker_on_node_after_target(
if let Some(blocker) = earliest_blocker_on_node_after_target(
py,
rows,
&self_index_all,
Expand All @@ -275,7 +286,7 @@ fn propagate_target_rows(
target_col,
kv,
)? {
continue;
earliest_blocker = Some(blocker);
}
}

Expand All @@ -290,7 +301,7 @@ fn propagate_target_rows(
}

if let Some(kv) = block_key {
if has_blocker_on_node_after_target(
if let Some(node_blocker) = earliest_blocker_on_node_after_target(
py,
rows,
&self_index_all,
Expand All @@ -299,7 +310,15 @@ fn propagate_target_rows(
target_col,
kv,
)? {
break;
let should_take = match &earliest_blocker {
None => true,
Some(current) => {
compare_bool(node_blocker.bind(py), current.bind(py), CompareOp::Lt)
}
};
if should_take {
earliest_blocker = Some(node_blocker);
}
}
}

Expand All @@ -321,20 +340,22 @@ fn propagate_target_rows(
break;
}

if let Some(kv) = block_key {
if matches_kv(&candidate, kv)? {
break;
}
}

let para_val = candidate
.get_item(para_col)?
.and_then(|x| x.extract::<String>().ok());
if para_val
.as_deref()
.is_some_and(|val| val.starts_with(para_prefix))
{
replacement = Some(candidate_target.unbind());
let target_before_block = match &earliest_blocker {
None => true,
Some(blocker_target) => {
compare_bool(&candidate_target, blocker_target.bind(py), CompareOp::Lt)
}
Comment on lines +350 to +354

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce blockers when propagating in backward mode

target_before_block only checks whether candidate_target is less than the blocker target. In backward mode, choose_candidate_by_plan already constrains candidates to <= anchor_target, while blockers are filtered to > anchor_target in earliest_blocker_on_node_after_target, so this condition becomes trivially true and block_key no longer blocks anything. As a result, rows that should stay unchanged when a later blocker exists can incorrectly inherit ancestor targets.

Useful? React with 👍 / 👎.

};
if target_before_block {
replacement = Some(candidate_target.unbind());
}
break;
}

Expand Down
7 changes: 6 additions & 1 deletion test/data/pivot_input.csv
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
id,parent_id,event,date
parent,root,Remove,2026-01-25
root,parent0,Remove,2026-01-02
root,parent0,Install,2026-01-01
root2,parent0,Install,2026-01-01
parent,root,Remove,2025-12-31
parent,root,Install,2025-12-31
parent,root,Install,2025-12-20
leaf,root2,Install,2025-12-01
leaf1,parent,Install,2025-12-01
leaf2,parent,Install,2025-12-01
leaf3,parent,Install,2025-12-01
20 changes: 19 additions & 1 deletion test/test_polars.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,34 @@ def _normalize(df: pl.DataFrame) -> pl.DataFrame:
def test_propagate_target_from_ancestor_from_local_csv() -> None:
csv_path = Path(__file__).parent / "data" / "pivot_input.csv"
source_df = pl.read_csv(csv_path, try_parse_dates=False)

source_df = (
source_df
.with_columns(pl.col("date").alias("install_date"))
.with_columns(pl.col("date").alias("remove_date"))
)

result_df = hello_rust.propagate_target_from_ancestor(
source_df,
self_cols=["id"],
parent_cols=["parent_id"],
target_col="date",
target_col="install_date",
target_key={"event": "Install"},
para={"parent_id": "parent0"},
plan="forward",
block_key={"event": "Remove"},
)

result_df = hello_rust.propagate_target_from_ancestor(
result_df,
self_cols=["id"],
parent_cols=["parent_id"],
target_col="remove_date",
target_key={"event": "Remove"},
para={"event": "Remove"},
plan="backward",
block_key={"event": "Install"},
)

# expected_df = source_df.with_columns(
# pl.when(pl.col("id") == "leaf").then(pl.lit(10)).otherwise(pl.col("ts")).alias("ts")
Expand All @@ -41,3 +58,4 @@ def test_propagate_target_from_ancestor_from_local_csv() -> None:
# print(expected_df.to_dicts())

# compare removed for now; print output only
# test command .\.venv\Scripts\python -m pytest -s -q test/test_polars.py