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
19 changes: 19 additions & 0 deletions v1/bom-reachability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ betweenness = graph.betweenness_centrality()

Components with high betweenness are structural bottlenecks -- disrupting them affects the most product lines.

### 5. Enumerate Assembly Paths (PREVIEW, requires `relationalai>=1.15`)

Where reachability returns dependency *pairs*, path enumeration returns the actual *build sequences*. It derives a SKU-to-SKU `feeds` edge from the `BillOfMaterials` intermediary (input SKU feeds output SKU) and enumerates every assembly path; because the BOM is acyclic, `.all_paths()` yields exactly the simple paths -- no cycle risk. A maximal-paths view keeps only the longest non-extendable chains, and the longest assembly depth is persisted as `SKU.assembly_depth`.

```python
SKU.feeds = model.Relationship(f"{SKU} feeds into {SKU}", short_name="feeds")
p = model.path(SKU.feeds.repeat(1, MAX_ASSEMBLY_HOPS)).all_paths()
assembly_df = (
model.where(p)
.select(
p.alias("path"),
p.nodes["index"].alias("step"),
SKU(p.nodes).id.alias("sku_id"),
SKU(p.nodes).name.alias("sku_name"),
)
.to_df()
)
```

## Customize this template

**Use your own data:**
Expand Down
135 changes: 129 additions & 6 deletions v1/bom-reachability/bom_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,28 @@
Algorithms: reachable(full=True) for transitive dependency tracing,
betweenness_centrality() for identifying structural bottlenecks.

Assembly path enumeration (PREVIEW, requires relationalai>=1.15): enumerate the
bottom-up assembly chains that build each finished good. Derives a binary
SKU->SKU "feeds into" edge from the BillOfMaterials intermediary (input feeds
output), enumerates every assembly path with model.path(...).all_paths(),
filters to the maximal (longest non-extendable) chains so sub-paths are
suppressed, and persists each finished good's longest assembly depth back onto
the SKU ontology.

Run:
`python bom_reachability.py`

Output:
Prints transitive dependency lists per finished product and a betweenness
centrality ranking that flags structural bottleneck components.
Prints transitive dependency lists per finished product, a betweenness
centrality ranking that flags structural bottleneck components, and the
maximal raw-material -> finished-good assembly chains with per-finished-good
assembly depth.
"""

from pathlib import Path

from pandas import read_csv
from relationalai.semantics import Float, Model, String, where
import pandas as pd
from relationalai.semantics import Float, Integer, Model, String, where
from relationalai.semantics.reasoners.graph import Graph

model = Model("bom_reachability")
Expand All @@ -38,7 +48,7 @@
SKU.type = model.Property(f"{SKU} has type {String:type}")
SKU.category = model.Property(f"{SKU} in {String:category}")

sku_data = model.data(read_csv(data_dir / "skus.csv"))
sku_data = model.data(pd.read_csv(data_dir / "skus.csv"))
model.define(SKU.new(id=sku_data["ID"]))
where(SKU.id == sku_data["ID"]).define(
SKU.name(sku_data["NAME"]),
Expand All @@ -51,7 +61,7 @@
BillOfMaterials.output_sku = model.Relationship(f"{BillOfMaterials} produces {SKU}")
BillOfMaterials.input_sku = model.Relationship(f"{BillOfMaterials} requires {SKU}")

bom_data = model.data(read_csv(data_dir / "bill_of_materials.csv"))
bom_data = model.data(pd.read_csv(data_dir / "bill_of_materials.csv"))
model.define(BillOfMaterials.new(id=bom_data["ID"]))
where(BillOfMaterials.id == bom_data["ID"]).define(
BillOfMaterials.output_sku(SKU.lookup(id=bom_data["OUTPUT_SKU_ID"])),
Expand Down Expand Up @@ -162,3 +172,116 @@
top = bottlenecks.iloc[0]
print(f"\nTop bottleneck: {top['sku_name']} (betweenness={top['betweenness']:.4f})")
print(" Sits on the most dependency paths -- disruption here affects the most product lines.")

# --------------------------------------------------
# Assembly path enumeration
# PREVIEW capability; requires relationalai>=1.15.
# --------------------------------------------------
# Where betweenness scores a single *node*, this enumerates the full *chains*
# that build each finished good: every bottom-up assembly path from a raw
# material up through its components to the finished good. We derive a binary
# SKU->SKU "feeds into" edge from the BillOfMaterials intermediary (the input
# SKU feeds the output SKU), enumerate all such paths, then keep only the
# maximal (longest, non-extendable) chains so that sub-paths are suppressed.

print("\n=== Assembly Path Enumeration (PREVIEW) ===")

# Binary SKU->SKU edge: input_sku "feeds into" output_sku. This is the
# build-direction (bottom-up) reverse of the depends-on graph edge above.
SKU.feeds = model.Relationship(f"{SKU} feeds into {SKU}", short_name="feeds")
bom_ref = BillOfMaterials.ref()
sku_in, sku_out = SKU.ref(), SKU.ref()
model.where(
bom_ref.input_sku(sku_in),
bom_ref.output_sku(sku_out),
).define(sku_in.feeds(sku_out))

# Enumerate every assembly chain. The BOM is a DAG (raw materials -> components
# -> finished goods, never back), so .all_paths() already yields simple paths --
# there is no cycle risk, and the repeat bound only needs to cover the BOM depth.
# Deepest chain here is raw_material -> component -> finished_good (2 hops); we
# allow extra headroom so deeper BOMs enumerate fully.
MAX_ASSEMBLY_HOPS = 4
p = model.path(SKU.feeds.repeat(1, MAX_ASSEMBLY_HOPS)).all_paths()
assembly_df = (
model.where(p)
.select(
p.alias("path"),
p.nodes["index"].alias("step"),
SKU(p.nodes).id.alias("sku_id"),
SKU(p.nodes).name.alias("sku_name"),
)
.to_df()
)

# Reassemble each path in pandas: group on the path id, order by step index.
assembly_df["step"] = assembly_df["step"].astype(int)
paths = []
for path_id, grp in assembly_df.groupby("path"):
ordered = grp.sort_values("step")
ids = ordered["sku_id"].tolist()
names = ordered["sku_name"].tolist()
paths.append({"ids": ids, "names": names, "length": len(ids) - 1})

print(f"\n Enumerated {len(paths)} assembly path(s) (<= {MAX_ASSEMBLY_HOPS} hops).")

# (a) All assembly paths, longest first.
print("\n All assembly chains (feeds-into order):")
for path in sorted(paths, key=lambda x: -x["length"]):
print(f" [{path['length']} hop] " + " -> ".join(path["names"]))

# (b) Maximal paths: keep only the longest, non-extendable chains -- a chain
# that is NOT a contiguous sub-sequence (prefix or suffix) of any longer chain.
# Suppressing these sub-paths leaves just the end-to-end assembly routes.
def _is_contiguous_subsequence(short, long):
"""True if `short` appears as a contiguous run inside `long`."""
if len(short) >= len(long):
return False
return any(long[i:i + len(short)] == short for i in range(len(long) - len(short) + 1))


maximal = [
path
for path in paths
if not any(
other is not path and _is_contiguous_subsequence(path["ids"], other["ids"])
for other in paths
)
]

print(f"\n Maximal assembly chains ({len(maximal)} of {len(paths)}, sub-paths suppressed):")
for path in sorted(maximal, key=lambda x: -x["length"]):
print(f" [{path['length']} hop] " + " -> ".join(path["names"]))

# Persist each finished good's longest assembly depth back onto the SKU ontology.
# The terminal SKU of a maximal chain is the thing it builds; the deepest chain
# ending there is that finished good's assembly depth.
SKU.assembly_depth = model.Property(f"{SKU} has assembly depth {Integer:assembly_depth}")
depth_by_sku = {}
for path in maximal:
terminal = path["ids"][-1]
depth_by_sku[terminal] = max(depth_by_sku.get(terminal, 0), path["length"])

if depth_by_sku:
depth_rows = pd.DataFrame(
[{"sku_id": sku_id, "assembly_depth": depth} for sku_id, depth in depth_by_sku.items()]
)
depth_data = model.data(depth_rows)
model.define(SKU.assembly_depth(depth_data.assembly_depth)).where(
SKU.id == depth_data.sku_id
)

print("\n Assembly depth persisted onto SKU (longest chain terminating at each):")
depth_df = (
where(SKU.assembly_depth > 0)
.select(
SKU.id.alias("sku_id"),
SKU.name.alias("sku_name"),
SKU.type.alias("type"),
SKU.assembly_depth.alias("assembly_depth"),
)
.to_df()
.sort_values("assembly_depth", ascending=False)
.reset_index(drop=True)
)
print(depth_df.to_string(index=False))
2 changes: 1 addition & 1 deletion v1/bom-reachability/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "RelationalAI template: bill of materials dependency tracing with
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"relationalai==1.11.0",
"relationalai==1.15.0",
"pandas>=2.0.0",
]

Expand Down
Loading
Loading