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
78 changes: 77 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,85 @@
History
=======

1.3.0 (2026-10-17)
1.4.0 (2026-10-21)
------------------

Two guards and a way to read a schema back. Nothing in a dataset changes
for a given seed.

The guards are both about not destroying something quietly. A dataset
directory could become the union of two runs, and ``schema draft`` could
write over a schema you had edited. Neither announced itself: the first was
reported as "the draft generates badly but validate is happy", which is
exactly what it looks like from outside.

* Two dependency advisories. ``certifi`` moves to 2026.10.19 for
PYSEC-2026-4320: earlier bundles trust ``emSign ECC Root CA - G3``, and
eight eMudhra intermediates chaining to it have compromised private keys,
so somebody holding one of those keys and sitting on the path can present
a valid-looking certificate for any hostname. Every HTTPS call the package
makes verifies against that bundle, which is the wiki fetcher, the BTS zip
the flights fetcher downloads and the Overpass queries behind the OSM one,
so it is a direct dependency now rather than whatever ``requests`` brings.

``pillow`` moves to 13.0.0 in the ``examples`` extra for eight
image-parsing CVEs. Nothing in GraphFaker opens an image: Pillow is there
because matplotlib draws the notebooks' plots, so none of it is reachable
from the package itself. The floor carries a ``python_version >= '3.11'``
marker, because Pillow 13 needs 3.11 and the package supports 3.10, and
keeping the extra installable there is worth more than forcing the choice.

* ``graphfaker validate --verbose`` reads a schema back to you.
``GraphSchema.describe()`` renders the thing somebody holding a few
hundred lines of drafted YAML actually needs, and it is not a
pretty-printer for the file: each attribute carries its sampler and the
sampler's parameters, because a gaussian where the column means money is
the commonest thing wrong with a written schema and a gaussian amount has
no tail; each edge family carries its budget **divided by its source
rows**, because a schema states a share of a total and nothing in the file
says whether that works out to one payment per purchase; and the latent
factor gets a line even when there is none, because a schema without one
is a pile of independent columns.

That middle number is the one that earns the flag. ``0.80 per Admission``
means a fifth of admissions land in no ward, and ``0.60 per Patient`` on a
functional relationship means 40% of patients have no primary physician.
Neither is visible in the YAML, and on a drafted customs schema the same
arithmetic was the entire finding. The summary lives on the schema rather
than in the CLI so a notebook can use it, which is where a draft actually
gets picked apart, and it judges nothing: the same contract the hardness
and coverage reports have.

* ``graphfaker schema draft`` will not write over an existing file.
A drafted schema is meant to be read and edited, and a second draft of
the same sentence is a different schema, so replacing one silently throws
away the editing that is the point of the file. Checked before the model
is asked, so a mistaken rerun costs nothing; ``--force`` overwrites. If a
dataset sits in the directory named after the schema, which is the
pairing people make, it says so rather than doing anything about it.

* A dataset directory can no longer become the union of two runs. The
write is per table and removed nothing, so generating a second dataset
into the same ``--out`` left both: the manifest said two node types and
the folder held four, and every loader reads the directory rather than
the manifest, so it read one graph made of two. ``GraphRun.write``
refuses when the directory holds tables the run does not replace, names
them, and writes nothing; ``--force`` on ``generate`` and ``fraud``
(``force=True`` from Python) replaces the dataset instead.

It only comes up when the set of tables changes, which means a renamed
or removed node type or a different schema entirely, so regenerating the
same schema is unaffected and needs no flag. Only ``nodes/``, ``edges/``
and ``truth/`` are considered and only ``.parquet`` in them, so a
directory holding your own files is neither reported on nor touched.

Reported by a user who drafted twice into the same name and found the
result generated badly while ``validate`` was happy, which is exactly
what this looks like from outside: the schema file was fine and the
directory was not.

1.3.0 (2026-10-17)
------------------
The schema debt paid: what a domain pack does over time is a declaration the
engine runs, rather than three Python files answering the same three questions
with different constants. A coverage report that says what a dataset actually
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ Both commands write `nodes/`, `edges/`, `truth/`, `schema.yaml` and `manifest.js
| `--coverage` | print what the dataset contains: every shape in the pack's catalogue, how many of each, the roles their members filled, where the pattern events sit in the ordinary process, which parts of the population were recruited, and each shape against the decoy that imitates it |
| `--json` | when done, print the manifest (and for `fraud`, the hardness, realism and coverage reports) as one JSON document on stdout. Logging goes to stderr, so a pipeline can read stdout |

Three commands look at datasets and schema files without generating anything. `graphfaker inspect ./bank` reads the manifest and the file layout and prints what a directory contains (schema, seed, versions, counts, which truth tables are there, the domain options), with `--json` for scripts. `graphfaker validate my_graph.yaml other.yaml` checks schema files with the same rules `generate --schema` applies and exits non-zero if any fails. `graphfaker evaluate ./bank --accounts flagged.txt --json` scores a detector's output as JSON.
Three commands look at datasets and schema files without generating anything. `graphfaker inspect ./bank` reads the manifest and the file layout and prints what a directory contains (schema, seed, versions, counts, which truth tables are there, the domain options), with `--json` for scripts. `graphfaker validate my_graph.yaml other.yaml` checks schema files with the same rules `generate --schema` applies and exits non-zero if any fails; `--verbose` reads a schema back to you, with each attribute's sampler and each edge family's budget divided by its source rows, which is the number that says whether every purchase has a payment. `graphfaker evaluate ./bank --accounts flagged.txt --json` scores a detector's output as JSON.

### What the options mean

Expand Down
3 changes: 3 additions & 0 deletions docs/reference/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Engine
.. automodule:: graphfaker.engine.seeding
:members: Streams

.. automodule:: graphfaker.schema.samplers
:members: describe_sampler, clock_dependent

.. automodule:: graphfaker.schema.process
:members: TimingModel, HourProfile, HourWindow, QuarterEndBoost, MonthlySchedule, Lognormal, Mixture, Rounding, ActorMix, ChannelSpec, ProcessSpec

Expand Down
30 changes: 29 additions & 1 deletion docs/semantic-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ drafted 'hospital' with anthropic (claude-opus-5-5)
read it before you generate from it: a drafted schema is a first guess, not an answer
```

The YAML goes to the file (or to stdout with no `--out`, so it can be piped); everything else goes to stderr. Then it is an ordinary schema:
The YAML goes to the file (or to stdout with no `--out`, so it can be piped); everything else goes to stderr. It will not write over a file that already exists: a draft is meant to be edited, and a second draft of the same sentence is a different schema, so replacing one would throw away your changes. Pass `--force` when that is what you want, and it is checked before the model is asked, so a mistaken rerun costs nothing.

Then it is an ordinary schema:

```sh
graphfaker validate hospital.yaml
Expand All @@ -81,6 +83,32 @@ The draft is validated before you see it. If it does not parse as a `GraphSchema

After a second failure it stops, exits non-zero, and prints the last attempt so you can see what it was reaching for. It does not write a file. A schema that does not validate is an error, not a dataset.

## Reading a draft back

A drafted schema is a few hundred lines of YAML, and `--verbose` reads it back to you:

```sh
graphfaker validate hospital.yaml --verbose
```

```
hospital.yaml: ok, schema 'hospital', 4 node types, 11 relationship types, 10,340 nodes and 24,000 edges, digest aab96a5d79
latent department x10: specialty category(8), mean_stay uniform(2, 12), mean_age uniform(8, 72)

Patient 4,000 name faker(name), age gaussian(@department.mean_age, 16), sex category(2),
blood_type category(8), city faker(city), phone faker(phone_number)
Admission 6,000 patient -> Patient, ward -> Ward, attending -> Doctor, admit_date
faker(date_time_between), total_cost expression, outcome category(4)

Admission -> Patient 6,000 1.00 per Admission ADMISSION_OF*
Admission -> Ward 4,800 0.80 per Admission ADMITTED_TO*
Patient -> Doctor 2,400 0.60 per Patient TREATED_BY, PRIMARY_PHYSICIAN*

* functional: a node has at most one of these
```

Three things are there on purpose. Each attribute carries its sampler and the sampler's parameters, because a gaussian where the column means money is the commonest thing wrong with a written schema. Each edge family carries its budget **divided by its source rows**, because a schema states a share of a total and nothing in the file says whether that works out to one payment per purchase: `0.80 per Admission` means a fifth of admissions land in no ward, and `0.60 per Patient` on a functional relationship means 40% of patients have no primary physician. And the latent factor gets a line even when there is none, because a schema without one is a pile of independent columns.

## What to check in a draft

The model is good at structure and unreliable about magnitude. In rough order of how often it is worth fixing:
Expand Down
2 changes: 1 addition & 1 deletion graphfaker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

__author__ = """Dennis Irorere"""
__email__ = "denironyx@gmail.com"
__version__ = "1.3.0"
__version__ = "1.4.0"

#: public name -> the module it lives in
_EXPORTS = {
Expand Down
64 changes: 56 additions & 8 deletions graphfaker/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,18 @@ def _dump(data: dict) -> None:
typer.echo(json.dumps(data, indent=2, default=str))


def _write_sink(run, out: str, sink: str, blind: bool = False) -> None:
def _write_sink(run, out: str, sink: str, blind: bool = False, force: bool = False) -> None:
"""Write the run as Parquet, then whatever extra layout ``sink`` asks for.

``blind`` keeps the ground truth out of a database sink (it is still
written to ``truth/`` on disk), so the database can be handed to a
detector as an unbiased benchmark."""
root = run.write(out)
detector as an unbiased benchmark. ``force`` replaces a dataset already
in the directory instead of refusing to half-overwrite it."""
try:
root = run.write(out, force=force)
except FileExistsError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(code=1) from exc
logger.info("wrote %s (%s)", root, ", ".join(f"{k}={v}" for k, v in run.manifest.node_counts.items()))
if sink == "parquet":
return
Expand Down Expand Up @@ -407,6 +412,7 @@ def generate(
blind: bool = typer.Option(False, "--blind", help="Keep the ground truth out of the database sink (it is still written to truth/ on disk)."),
quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress logging; warnings and errors only."),
coverage: bool = typer.Option(False, "--coverage", help="Print what the dataset contains: shapes, roles, regimes, segments, decoy twins."),
force: bool = typer.Option(False, "--force", help="Replace a dataset already in --out. Without it, a write that would leave an earlier run's tables behind is refused."),
as_json: bool = typer.Option(False, "--json", help="Print the manifest as JSON on stdout when done."),
):
"""Examples:
Expand All @@ -427,7 +433,7 @@ def generate(

loaded = _load_schema(schema)
run = _generate(loaded, seed=seed, shard_size=shard_size or DEFAULT_SHARD_SIZE, workers=workers)
_write_sink(run, out, sink, blind)
_write_sink(run, out, sink, blind, force)
if as_json:
_dump(_with_coverage(run.manifest.model_dump(), run, coverage))
elif coverage:
Expand All @@ -448,7 +454,7 @@ def generate(
except ValidationError as exc:
problems = "; ".join(f"--{'.'.join(str(p) for p in e['loc']).replace('_', '-')}: {e['msg']}" for e in exc.errors())
raise typer.BadParameter(f"{problems}. Run `graphfaker domains` to see the options.") from exc
_write_sink(run, out, sink, blind)
_write_sink(run, out, sink, blind, force)
if as_json:
_dump(_with_coverage(run.manifest.model_dump(), run, coverage, pack.patterns))
elif coverage:
Expand All @@ -472,11 +478,17 @@ def _with_coverage(payload: dict, run, wanted: bool, catalog=None) -> dict:
@app.command(short_help="Check schema YAML files without generating anything.")
def validate(
files: list[str] = typer.Argument(..., help="Schema YAML files, as written by `graphfaker schema` or by hand."),
verbose: bool = typer.Option(
False, "--verbose", "-v",
help="Read the schema back: node types with their samplers, edge families with the budget per source row, the latent factor.",
),
):
"""Parse and validate each file with the same checks `generate --schema`
applies, and say what it describes. Exits non-zero if any file fails.

graphfaker validate my_graph.yaml other.yaml

graphfaker validate hospital.yaml --verbose
"""
failed = False
for path in files:
Expand All @@ -490,6 +502,11 @@ def validate(
f"{path}: ok, schema {loaded.name!r}, {len(loaded.nodes)} node types, {len(loaded.relationships())} relationship types, "
f"{loaded.total_nodes:,} nodes and {loaded.total_edges:,} edges, digest {loaded.digest()}"
)
if verbose:
# The header line says how big it is; this says what it is, which
# is the question somebody holding a drafted schema has.
for line in loaded.describe()[1:]:
typer.echo(line)
for line in _clock_warnings(loaded):
typer.echo(line, err=True)
if failed:
Expand Down Expand Up @@ -685,6 +702,7 @@ def schema_command(
model: str = typer.Option(None, "--model", help="draft only: the model to ask. Default $GRAPHFAKER_LLM_MODEL, or Anthropic's most capable."),
base_url: str = typer.Option(None, "--base-url", help="draft only: an OpenAI-compatible endpoint, for a local model."),
name: str = typer.Option(None, "--name", help="draft only: what to call the schema."),
force: bool = typer.Option(False, "--force", help="draft only: overwrite --out if it already exists."),
):
"""Examples:

Expand All @@ -697,7 +715,7 @@ def schema_command(
from graphfaker.domains import available, get

if domain == "draft" and "draft" not in available():
_draft_schema(list(ctx.args), out, provider, model, base_url, name)
_draft_schema(list(ctx.args), out, provider, model, base_url, name, force)
return
try:
pack = get(domain)
Expand Down Expand Up @@ -732,7 +750,7 @@ def schema_command(

def _draft_schema(
words: list[str], out: str | None, provider: str | None, model: str | None,
base_url: str | None, name: str | None,
base_url: str | None, name: str | None, force: bool = False,
) -> None:
"""``graphfaker schema draft "<description>"``.

Expand All @@ -749,6 +767,16 @@ def _draft_schema(
raise typer.BadParameter(
'describe the graph you want: graphfaker schema draft "a hospital: patients, doctors, wards"'
)
# Checked before the model is asked, so a refusal costs nothing. A
# drafted schema is a file you are meant to read and edit, and a second
# draft of the same sentence is a different schema: overwriting one
# silently would throw away the editing that is the whole point.
if out is not None and Path(out).exists() and not force:
raise typer.BadParameter(
f"{out} already exists. A drafted schema is meant to be edited, and a second draft is a "
"different schema, so this would replace whatever you changed. Write to another file, or "
"pass --force."
)
try:
client = get_provider(provider, model, base_url)
except ProviderError as exc:
Expand Down Expand Up @@ -778,9 +806,28 @@ def _draft_schema(
else:
Path(out).write_text(header + result.yaml, encoding="utf-8")
typer.echo(f"wrote {out}", err=True)
for line in _stale_dataset_note(Path(out)):
typer.echo(line, err=True)
typer.echo(result.summary(), err=True)


def _stale_dataset_note(schema_path: Path) -> list[str]:
"""Point at a dataset generated from the schema this file used to hold.

``hospital.yaml`` and ``./hospital`` is the obvious pairing and the one
people make, so a redraft leaves a directory whose data no longer comes
from the file next to it. Nothing is touched; it only says so.
"""
candidate = schema_path.parent / schema_path.stem
manifest = candidate / "manifest.json"
if not manifest.is_file():
return []
return [
f"note: {candidate} holds a dataset generated from the previous draft. "
f"Regenerate it with --force, or write this one somewhere else."
]


@app.command(short_help="Generate a fraud / AML transaction graph with labelled typologies.")
def fraud(
scale: float = typer.Option(0.001, help="1.0 = ~10M accounts / ~90M transactions (gen-fraud-graph convention)."),
Expand All @@ -796,6 +843,7 @@ def fraud(
report: bool = typer.Option(True, help="Print the hardness and realism reports."),
blind: bool = typer.Option(False, "--blind", help="Keep the ground truth out of the database sink (it is still written to truth/ on disk)."),
quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress logging; warnings and errors only."),
force: bool = typer.Option(False, "--force", help="Replace a dataset already in --out."),
as_json: bool = typer.Option(False, "--json", help="Print the manifest and the reports as one JSON document on stdout."),
):
"""Shortcut for `graphfaker generate fraud ...` that also prints the reports."""
Expand All @@ -805,7 +853,7 @@ def fraud(

_quiet(quiet)
run = get("fraud").run(seed=seed, workers=workers, scale=scale, hardness=hardness, period_days=period_days)
_write_sink(run, out, sink, blind)
_write_sink(run, out, sink, blind, force)
if as_json:
_dump({
"manifest": run.manifest.model_dump(),
Expand Down
Loading
Loading