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 docs/howto/supplier_tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ print(mapping2mermaid(rows, title=title))
The table below shows the values for the decision model.
Each row of the table corresponds to a path through the decision model diagram above.

% include-markdown "../_includes/_scrollable_table.md" heading-offset=1 %}
{% include-markdown "../_includes/_scrollable_table.md" heading-offset=1 %}

```python exec="true" idprefix=""

Expand Down
4 changes: 2 additions & 2 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ After installation, import the package and explore the examples:
print(to_play.LATEST.model_dump_json(indent=2))

#Show decision tree in ascii text art
from ssvc.decision_tables.base import ascii_tree
from ssvc.decision_tables.helpers import ascii_tree
print(ascii_tree(to_play.LATEST))

Explanation
Expand Down Expand Up @@ -92,7 +92,7 @@ For usage in vulnerability management scenarios consider the following popular S
print(CISACoordinate.model_dump_json(indent=2))

#Print CISA Decision Table as an ascii tree
from ssvc.decision_tables.base import ascii_tree
from ssvc.decision_tables.helpers import ascii_tree
print(ascii_tree(CISACoordinate))


Expand Down
87 changes: 3 additions & 84 deletions src/ssvc/decision_tables/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,88 +702,7 @@ def check_topological_order(dt: DecisionTable) -> list[dict]:
df, target=target, target_value_order=target_value_order
)

def build_tree(df: pd.DataFrame, columns: pd.Index | list[str]) -> dict[str, dict[str, str] | list[str]] | list[str]:
"""
Recursively build a nested dict:
{feature_value: subtree_or_list_of_outcomes}

This tree should preserve the original row order of the DataFrame.
"""
# Base case: if only one column is left, it's the outcome.
if len(columns) == 1:
# Last column: return a list of outcomes.
return df[columns[0]].astype(str).tolist()

# Get the first feature column and the rest of the columns.
first, rest = columns[0], columns[1:]
tree = {}

# Iterate through the unique values of the first column in the order they appear.
# This is the key change to preserve the original CSV order.
for val in df[first].unique():
# Filter the DataFrame to get the group for the current value.
group = df[df[first] == val]
# Recursively build the subtree for this group.
tree[str(val)] = build_tree(group, rest)

return tree

def draw_tree(node: dict | list, prefix: str="", lines: list | None = None) -> list:
"""
Pretty-print nested dict/list as a tree.
"""
if lines is None:
lines = []

if isinstance(node, dict):
items = list(node.items())
for i, (k, v) in enumerate(items):
# Determine the branch characters for the tree.
branch = "└── " if i == len(items) - 1 else "├── "
lines.append(prefix + branch + k + " " * 4)

# Calculate the prefix for the next level of the tree.
next_prefix = prefix + (" " * 16 if i == len(items) - 1 else "│" + " " * 15)
# Recursively draw the subtree.
draw_tree(v, next_prefix, lines)
else: # list of outcomes
for i, leaf in enumerate(node):
# Determine the branch characters for the leaves.
branch = "└── " if i == len(node) - 1 else "├── "
lines.append(prefix + branch + f"[{leaf}]")

return lines

def ascii_tree(dt: DecisionTable, df: pd.DataFrame | None = None) -> str:
"""
Reads a Pandas data frame, builds a decision tree, and returns its ASCII representation.
"""
# Check for the optional 'row' column and drop it if it exists.
if df == None:
df = decision_table_to_longform_df(dt)

if 'row' in df.columns:
df.drop(columns='row', inplace=True)

# Separate feature columns from the outcome column.
feature_cols = list(df.columns[:-1])
outcome_col = df.columns[-1]

# Build the tree structure.
tree = build_tree(df, feature_cols + [outcome_col])
# Draw the tree into a list of strings.
lines = draw_tree(tree)

# Generate the header line.
header = ""
for item in df.columns:
if len(item) > 14:
header = header + item[0:12] + ".." + " | "
else:
header = header + item + " " * (14 - len(item)) + " | "

# Generate the separator line.
sep = "-" * len(header)

# Combine the header, separator, and tree lines into a single string.
return "\n".join([header, sep] + lines)
""" Function moved to helpers.py see there for details """
from . import helpers
return helpers.ascii_tree(dt, df)
160 changes: 158 additions & 2 deletions src/ssvc/decision_tables/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@


import logging
import pandas as pd

from ssvc.decision_tables.base import (
DecisionTable,
Expand Down Expand Up @@ -215,7 +216,7 @@ def mapping2mermaid(rows: list[dict[str:str]], title: str = None) -> str:


def dt2df_md(
dt: "DecisionTable",
dt: DecisionTable,
longform: bool = True,
) -> str:
"""
Expand All @@ -224,7 +225,7 @@ def dt2df_md(
decision_table (DecisionTable): The decision table to convert.
longform (bool): Whether to return the longform or shortform DataFrame.
Returns:
str: A string representation of the DataFrame in CSV format.
str: A string representation of the DataFrame in text/markdown format.
"""
if longform:
df = decision_table_to_longform_df(dt)
Expand All @@ -234,6 +235,161 @@ def dt2df_md(
df.index.rename("Row", inplace=True)
return df.to_markdown(index=True)

def dt2df_html(
dt: DecisionTable,
longform: bool = True) -> str:
"""
Converts a Decision Tree and represent it in friendly HTML Code
Args:
decision_table (DecisionTable): The decision table to convert.
longform (bool): Whether to return the longform or shortform DataFram, defaults to true
Returns:
str: A string representation of the DataFrame in HTML format.
"""

if longform:
df = decision_table_to_longform_df(dt)
else:
df = decision_table_to_shortform_df(dt)

df = decision_table_to_longform_df(dt)
ncols = len(df.columns)
nrows = len(df)

# Precompute rowspan info for every cell
# rowspan[i][j] = number of rows this cell should span; 0 means skip (because merged above)
rowspan = [[1]*ncols for _ in range(nrows)]

for col in range(ncols):
r = 0
while r < nrows:
start = r
val = df.iat[r, col] #data_rows[r][col]
# Count how many subsequent rows have same value
while r + 1 < nrows and df.iat[r + 1, col] == val:#data_rows[r + 1][col] == val:
r += 1
span = r - start + 1
if span > 1:
# Assign span to first, mark rest as 0 (skip)
rowspan[start][col] = span
for k in range(start + 1, start + span):
rowspan[k][col] = 0
r += 1

# Build HTML
html = ["""<style>
table,th,td,tr { border-spacing: 0px; border: 1px solid cyan; padding: 0px; font-family: verdana,courier }
.decision_table th { font-weight: bold; }
td.decision_point { vertical-align: middle}
td.outcome { font-style: italic; font-weight: bold}
</style>"""]
html.append("<table class=\"decision_table\">")
html.append(" <tr>" + "".join(f"<th>{h}</th>" for h in df.columns) + "</tr>")

for i, row in df.iterrows(): #for i, row in enumerate(df):
cells = []
j = 0
for _, val in row.items(): #enumerate(row):
tdtype = "decision_point"
if j == len(row) - 1:
tdtype = "outcome"
if rowspan[i][j] > 0:
span = rowspan[i][j]
if span > 1:
cells.append(f'<td rowspan="{span}" class="{tdtype}">{val}</td>')
else:
cells.append(f'<td class="{tdtype}">{val}</td>')
j = j + 1
html.append(" <tr>" + "".join(cells) + "</tr>")

html.append("</table>")
return "".join(html)

def build_tree(df: pd.DataFrame, columns: pd.Index | list[str]) -> dict[str, dict[str, str] | list[str]] | list[str]:
"""
Helper function recursively build a nested dict:
{feature_value: subtree_or_list_of_outcomes}
This tree should preserve the original row order of the DataFrame.
"""
# Base case: if only one column is left, it's the outcome.
if len(columns) == 1:
# Last column: return a list of outcomes.
return df[columns[0]].astype(str).tolist()

# Get the first feature column and the rest of the columns.
first, rest = columns[0], columns[1:]
tree = {}

# Iterate through the unique values of the first column in the order they appear.
# This is the key change to preserve the original CSV order.
for val in df[first].unique():
# Filter the DataFrame to get the group for the current value.
group = df[df[first] == val]
# Recursively build the subtree for this group.
tree[str(val)] = build_tree(group, rest)

return tree

def draw_tree(node: dict | list, prefix: str="", lines: list | None = None) -> list:
"""
Pretty-print nested dict/list as a tree.
"""
if lines is None:
lines = []

if isinstance(node, dict):
items = list(node.items())
for i, (k, v) in enumerate(items):
# Determine the branch characters for the tree.
branch = "└── " if i == len(items) - 1 else "├── "
lines.append(prefix + branch + k + " " * 4)

# Calculate the prefix for the next level of the tree.
next_prefix = prefix + (" " * 16 if i == len(items) - 1 else "│" + " " * 15)
# Recursively draw the subtree.
draw_tree(v, next_prefix, lines)
else: # list of outcomes
for i, leaf in enumerate(node):
# Determine the branch characters for the leaves.
branch = "└── " if i == len(node) - 1 else "├── "
lines.append(prefix + branch + f"[{leaf}]")

return lines

def ascii_tree(dt: DecisionTable, df: pd.DataFrame | None = None) -> str:
"""
Reads a Pandas data frame, builds a decision tree, and returns its ASCII representation.
"""
# Check for the optional 'row' column and drop it if it exists.
if df == None:
df = decision_table_to_longform_df(dt)

if 'row' in df.columns:
df.drop(columns='row', inplace=True)

# Separate feature columns from the outcome column.
feature_cols = list(df.columns[:-1])
outcome_col = df.columns[-1]

# Build the tree structure.
tree = build_tree(df, feature_cols + [outcome_col])
# Draw the tree into a list of strings.
lines = draw_tree(tree)

# Generate the header line.
header = ""
for item in df.columns:
if len(item) > 14:
header = header + item[0:12] + ".." + " | "
else:
header = header + item + " " * (14 - len(item)) + " | "

# Generate the separator line.
sep = "-" * len(header)

# Combine the header, separator, and tree lines into a single string.
return "\n".join([header, sep] + lines)


def main():
pass
Expand Down
Loading