-
Notifications
You must be signed in to change notification settings - Fork 3
Extending logdag
This page is a developer how-to for the two most common extensions:
-
Adding a causal-discovery method — a
new algorithm adapter selectable via
[dag] cause_algorithm. -
Adding a data source / evdb backend —
a new feature-database backend selectable via
[general] evdb.
Both extensions follow the same shape: logdag selects the implementation with an
if/elif dispatcher keyed on a config option, so adding one means (a) writing a
new module that satisfies a known interface and (b) wiring one new branch into
the dispatcher. Read Architecture Overview first for the
data model and the dispatch points; this page makes those concrete.
Grounding: the interfaces and steps below are taken from the existing adapters (
pc_input.py,lingam_input.py), the dispatchermakedag.estimate_dag, the prior-knowledge feedpknowledge.py, the evdb factorysource/evgen_common.py, theTimeSeriesDBABC insource/sqlts.py, the existing backends (influx.py,influx3.py), and the contract suitetests/contract/test_tsdb_contract.py. Nothing here is speculative; signatures are copied from the code.
For each job, makedag.makedag_main builds the input and then calls:
graph = estimate_dag(conf, input_df, prior_knowledge)makedag.estimate_dag is the single dispatch point. It:
- Returns an empty DAG immediately if
input_df.shape[1] < 2(fewer than two event variables) — your adapter is never called for trivial inputs. - Reads
cause_algorithm = conf.get("dag", "cause_algorithm")and a sharedci_func = conf.get("dag", "ci_func"). - Branches on
cause_algorithm, pulls the options each method needs from the config, calls the adapter, and returns its graph. A value that is not a built-in is resolved as an out-of-tree plugin (§1.6); only if no plugin is registered does it raiseValueError("invalid dag.cause_algorithm").
The currently reachable values are pc, lingam, pc-corr, and lingam-corr.
There is no shared abstract base class for method adapters. The contract is
informal but firm, derived from what estimate_dag does with the return value
and what the rest of the pipeline expects:
Adapter contract. A method adapter is a function that takes the input
DataFrame(rows = time bins, columns = integer event ids /eid), plus its own algorithm parameters and an optionalprior_knowledge, and returns anetworkx.DiGraphwhose nodes are the input column labels (theeids). Directed edges are single arcs; an undirected edge is encoded as a pair of opposite arcs between the same two nodes. Optionally attach a numericweightedge attribute (LiNGAM does — it enables theate_prunefilter). ReturnNoneto signal estimation failure (makedag_maintreatsNoneas a failed job); return an emptyDiGraphfor "no edges".
Why these specifics matter:
-
Node labels =
eids.showdag.LogDAGresolves every node back to an event via the evmap (node_evdef(eid)). If your graph relabels nodes to anything other than the input columns, node→event resolution breaks. Build the graph ondata.columns(aspc_inputandlingam_inputdo). -
Undirected = two arcs.
LogDAG.edge_isdirected,edges_by_direction, and thedirected/undirectedfilters all infer directedness from the presence of the reverse arc. A PC CPDAG already uses this convention viapcalg.estimate_cpdag; if your method emits truly undirected edges, add both arcs. -
weightis optional but useful. Theate_prunefilter (Viewing and Filtering DAGs) prunes byabs(weight); methods without weights simply cannot be ATE-pruned.
PC (pc_input.pc) — selects a CI test, optionally binarizes, builds an
initial skeleton, and returns a CPDAG:
def pc(data, threshold, mode="gsq", skel_method="stable",
pc_depth=None, verbose=False, prior_knowledge=None):
if prior_knowledge:
init_graph = prior_knowledge.pruned_initial_skeleton()
else:
init_graph = nx.complete_graph(data.columns)
if mode == "gsq":
from gsq.ci_tests import ci_test_bin
func = ci_test_bin
data = binarize_input(data)
elif mode in ("fisherz", "fisherz_bin"):
from citestfz.ci_tests import ci_test_gauss
func = ci_test_gauss
...
return estimate_dag(data, threshold, func, skel_method,
pc_depth, verbose, init_graph) # -> CPDAG DiGraphLiNGAM (lingam_input.estimate) — fits a LiNGAM model and emits a weighted
DiGraph on data.columns:
def estimate(data, algorithm="ica", lower_limit=0.01,
ica_max_iter=1000, prior_knowledge=None):
import lingam
... # fit ICALiNGAM / DirectLiNGAM
g = nx.DiGraph()
for i in range(adj.shape[0]):
g.add_node(i)
... # for each significant coefficient:
g.add_edge(from_, to, weight=coef, label=str(round(coef, 2)))
return gNote the lazy import lingam / import pcalg inside the function: this keeps
heavy / optional dependencies out of the import path for users who do not select
that method. Follow the same pattern.
prior_knowledge is a pknowledge.PriorKnowledge instance (or None).
There is no single uniform way methods consume it — pick the feed that fits
your algorithm:
-
Skeleton-based methods (PC) call
prior_knowledge.pruned_initial_skeleton(), which returns an undirectednetworkx.Graphover all node ids with no-edge pairs removed. Use it as theinit_graphfor skeleton estimation. -
LiNGAM calls
prior_knowledge.lingam_prior_knowledge()(optionally withnode_ids=[i, j]for the pairwiseestimate_corrvariant), which returns a constraint matrix built bylingam.utils.make_prior_knowledgefrom the recorded paths / no-paths / exogenous / sink variables.
Your adapter should accept prior_knowledge=None and degrade gracefully (PC
falls back to a complete graph; ICA-LiNGAM ignores prior knowledge with a
warning). You do not need to modify pknowledge.py to add a method — it
already exposes both feeds.
-
Create
logdag/<name>_input.pywith anestimate(...)-style function that satisfies the adapter contract (§1.2). Do heavy imports lazily inside the function. Build the graph ondata.columns. -
Add a branch to
makedag.estimate_dagfor yourcause_algorithmvalue. Read whatever options you need from config there and pass them positionally / by keyword to your adapter (mirror an existing branch):elif cause_algorithm == "<name>": from . import <name>_input my_opt = conf.getfloat("dag", "<your_option>") # or a new [section] return <name>_input.estimate(input_df, my_opt, prior_knowledge=prior_knowledge)
-
Declare any new config options in
logdag/data/config.conf.default— add them to[dag]or to a dedicated section (LiNGAM uses[lingam]). Documenting defaults there makes them visible inlogdag show-default-configand on Configuration Options. -
Choose a prior-knowledge feed (skeleton vs. matrix) and consume it, or accept
Noneand ignore it. -
Verify with a small run (see Generating DAGs) using
cause_algorithm = <name>, and confirm the result loads and resolves:logdag show-edge-list -c <conf>should print edges with event labels — that exercises node→event resolution and so validates that you built the graph oneidnode labels.
You do not have to edit estimate_dag at all. If a cause_algorithm value is not
one of the built-ins, estimate_dag resolves it through the
logdag.cause_algorithm entry-point group: an installed package that registers
an entry point in that group — whose value points to an
estimate(conf, input_df, prior_knowledge=None) -> networkx.DiGraph callable — is
invoked for that name. Only if no such plugin is registered does the value fall
through to ValueError("invalid dag.cause_algorithm").
This lets a method that cannot live in the public tree (for example, a wrapper
around a private library) be installed and selected as cause_algorithm = <name>
without its code or name appearing in logdag. Unlike a built-in adapter — whose
parameters estimate_dag extracts from config and passes in — a plugin receives
the merged conf and reads its own [section] options itself; it must return a
DiGraph satisfying the same return contract as a built-in (§1.2: nodes are
the input eid columns, an undirected edge is a pair of opposite arcs, and a
numeric weight is optional). Declare the entry point in your plugin package,
e.g. in pyproject.toml:
[project.entry-points."logdag.cause_algorithm"]
<name> = "your_package.module:estimate""Data source" can mean two different extension points; be clear which you need:
- A new evdb backend (a new storage for extracted features) — the common
case, covered here in detail. You implement the
TimeSeriesDBcontract and wire one branch into the evdb factory. - A new input source class (a new origin of raw events, beyond
logandsnmp) — a larger change touching the event-definition model and several dispatchers; outlined in §2.5.
Every evdb backend subclasses the TimeSeriesDB ABC in source/sqlts.py and
implements its abstract methods:
class TimeSeriesDB(ABC):
@abstractmethod
def list_measurements(self): ...
@abstractmethod
def list_series(self, measure): ...
@abstractmethod
def list_fields(self, measure): ...
@abstractmethod
def add(self, measure, d_tags, d_input, columns): ...
@abstractmethod
def commit(self): ...
@abstractmethod
def get_items(self, measure, d_tags, fields, dt_range): ...
@abstractmethod
def get_df(self, measure, d_tags, fields, dt_range,
str_bin=None, func=None, fill=None, limit=None): ...
@abstractmethod
def get_count(self, measure, d_tags, fields, dt_range): ...
@abstractmethod
def drop_measurement(self, measure): ...The ABC also provides static timestamp helpers (pdtimestamp,
pdtimestamp_naive, pdtimestamps) delegating to source.convert, so all
backends localize/convert time the same way — inherit them, do not re-implement.
Behavioural contract (asserted by tests/contract/test_tsdb_contract.py;
these are the rules new backends must reproduce):
-
add(measure, d_tags, d_input, columns)writes rows.d_tagsmaps tag→value (e.g.{"host": ..., "key": ...});d_inputmaps timestamp→row-of-values;columnsare the field names.commit()flushes. -
get_df(..., func=None)over an empty range returnsNone, not an emptyDataFrame. -
get_df(..., func="sum", str_bin=<dur>)returns a per-bin aggregated frame (the read path used by feature loading; seeEventLoader.load). -
get_dfreturns a wide frame: index is a tz-aware localDatetimeIndex, columns are the field names. (Backends that store naive UTC, like the Influx ones, must localize-UTC then convert to local.) -
get_count(...)over an empty range returns0(anint, neverNone). -
get_items(...)yields(datetime, numpy.array)pairs. -
drop_measurement(measure)removes the measurement and is safe to call when the measurement does not exist (it must no-op, becausedrop_featuresiterates over every possible feature name regardless of what was actually written).
Asymmetry warning: some convenience methods (e.g.
has_data) exist on the Influx backends but are not part of the ABC. Cross-backend code (and theEventLoaderbase) only relies on the abstract methods above. Do not assume a method exists on all backends — check the concrete class.
source/evgen_common.EventLoader._init_evdb is the single place a backend is
chosen, keyed on [general] evdb:
def _init_evdb(self, conf, dbname_key):
db_type = conf["general"]["evdb"]
if db_type == "influx":
dbname = conf["database_influx"][dbname_key]
from . import influx
return influx.init_influx(conf, dbname, df=False)
elif db_type == "influx3":
dbname = conf["database_influx3"][dbname_key]
from . import influx3
return influx3.init_influx_v3(conf, dbname)
elif db_type in ("sql", "sqlite", "mysql"):
from . import sqlts
return sqlts.init_sqlts(conf)
else:
raise NotImplementedErrordbname_key is "log_dbname" or "snmp_dbname"; each loader passes the right
one (e.g. LogEventLoader calls self._init_evdb(conf, "log_dbname")). Your
factory receives the resolved database name and the config and returns a
TimeSeriesDB instance.
source/influx3.py is the most recent backend and the cleanest template,
because it implements the whole TimeSeriesDB contract from scratch over a
thin stdlib HTTP client (no third-party driver, hence no new dependency):
-
class InfluxDBv3(TimeSeriesDB)— writes viaPOST /api/v3/write_lp(Line Protocol) and reads viaGET /api/v3/query_sql(DataFusion SQL, CSV). -
init_influx_v3(conf, dbname)— the factory: reads[database_influx3](host/port, optionaltoken,batch_size), with the bearer token taken from theINFLUXDB_V3_TOKENenv var in preference to the config file, and returns the instance.
Its module docstring lists exactly which contract semantics it reproduces
(empty-range None/0, local tz-aware index, tag isolation) — a good checklist
when writing your own. The v3 backend is wired and contract-tested, but it is
newer than the sql and influx (v1) backends and has had less production
mileage; treat new backends as needing the same contract-test green bar before
trusting them.
-
Create
logdag/source/<backend>.pywith aclass MyTSDB(TimeSeriesDB)implementing all abstract methods and honoring the behavioural contract in §2.1. -
Add a factory
init_<backend>(conf, dbname)that reads your config section and returns the instance (mirrorinit_influx_v3/init_sqlts). -
Add a branch to
evgen_common.EventLoader._init_evdbfor your[general] evdbvalue, importing your module lazily and calling the factory with the rightdbname_key. -
Add a config section (e.g.
[database_<backend>]) with connection options and thelog_dbname/snmp_dbnamekeys tologdag/data/config.conf.default, so[general] evdb = <backend>is self-describing on Configuration Options. -
Add the backend to the contract suite.
tests/contract/test_tsdb_contract.pyparameterizes over backends; wiring yours in is how you prove equivalence tosql/influx. Optional/network backends should skip cleanly when the server is absent (the repo convention is skip-by-default with a "required" gate, so the suite fails loud only where the backend is expected). -
Build an evdb and run a DAG end-to-end (
logdag.source make-evdb-log-allthenlogdag make-dag; see Generating DAGs) withevdb = <backend>to confirm the write/read round-trip feeds DAG generation.
Adding a new origin of events (beyond log / snmp) is more invasive because
the source class threads through the event-definition model and several
dispatchers. At minimum you would:
- Add a source constant alongside
log2event.SRCCLS_LOG/SRCCLS_SNMPand a branch inlog2event.init_evloader(and theload_event_*_allhelpers). - Implement a loader that subclasses
source/evgen_common.EventLoaderand a matchingEventDefinitionsubclass (mirroringevgen_log.LogEventDefinition/LogEventLoader) that implementsidentifier,event(),series(),all_attr(), anditer_evdef(). - Provide a raw-data adapter (mirroring
src_amulog.AmulogLoader/src_rrd) for your origin. - Extend
pknowledge.AdditionalSource/ source-aware filters (subgraph_with_log/subgraph_with_snmpinshowdag_filter.py) if the new source should participate in source-based pruning/filtering.
This is a genuine feature, not a one-branch edit; scope it accordingly.