Case Study: Intelligence Agency
This case study largely extends the scenario of the paper that first presented ProvSQL [Senellart et al., 2018]. Through a security-classification scenario, it demonstrates custom semirings, clearance levels under negation, where-provenance, probability computation with several algorithms, and circuit export.
The Scenario
An intelligence agency maintains a database of seven employees spread across three cities. Each employee’s record is classified at a level ranging from unclassified to top secret: the clearance an analyst needs to know that the employee exists. Your tasks:
identify which cities are served by more than one agent,
determine the minimum clearance level needed to infer each result,
find cities with exactly one agent (sensitive: whatever is traced back to that city’s station points to the one agent there),
track where in the database each output value originated,
compute the probability that a city remains a single-agent post after accounting for possible-world uncertainty,
find at which clearance levels an analyst would wrongly conclude that a city has a single agent, and which allied services would.
Setup
Tip
Prefer not to install? Use the Playground. You can skip the manual
setup below: open this case study as a runnable notebook in the ProvSQL
Playground (every query is a
cell, and the opening cells set up the database for you), or open the bare
cs1 database and run the queries
as you read. The Playground bundles no external tools, so the steps that
call an external knowledge compiler (d4, c2d…) or the graph-easy
ASCII renderer (view_circuit) detect its absence and report it
instead of returning a result – the cells still run cleanly; the default
probability evaluation works throughout (it chooses among the built-in
exact methods), as does everything else. See the
Playground note.
This case study assumes a working ProvSQL installation (see
Getting ProvSQL). Download setup.sql
and load it into a fresh PostgreSQL database:
psql -d mydb -f setup.sql
The setup script calls setup_search_path, which adds
provsql to the database’s search_path: every new session calls
ProvSQL’s functions without the provsql. prefix.
This creates:
classification_level– an ordered ENUM (unclassified<restricted<confidential<secret<top_secret<unavailable) whereunavailableis a sentinel representing the semiring 𝟘 (no derivation possible)personnel– 7 agents with name, position, city, and the classification level of their record
Step 1: Explore the Database
Inspect the personnel table:
SELECT * FROM personnel ORDER BY id;
You should see seven rows: Juma (Director, Nairobi), Paul (Janitor, Nairobi), David (Analyst, Paris), Ellen (Field agent, Beijing), Aaheli (Double agent, Paris), Nancy (HR, Paris), and Jing (Analyst, Beijing).
Step 2: Enable Provenance and Create a Name Mapping
Enable provenance tracking on personnel and create a mapping so that
provenance tokens can be labelled with agent names:
SELECT add_provenance('personnel');
DROP TABLE IF EXISTS personnel_name;
SELECT create_provenance_mapping('personnel_name', 'personnel', 'name');
After add_provenance, every row of personnel has a unique UUID
token in its provsql column. The mapping personnel_name
associates each token with the corresponding agent’s name.
Step 4: Minimum Security Clearance (sr_minmax)
For each shared city, what is the minimum clearance level required to have inferred that the city has multiple agents? The analyst must see both agents of some pair in the city.
The min-max m-semiring, available as sr_minmax, answers this
over the classification_level enum:
⊕(OR combination) =min: to infer either agent was involved, you only need clearance for the less-classified one (one witness suffices to establish the disjunction).⊗(AND/join) =max: to confirm both agents are present, you need clearance for the more-classified one (you must be able to access both records to establish the join).
The third argument gives the carrier enum: only its type is used, so it is
written NULL::classification_level:
DROP TABLE IF EXISTS personnel_level;
SELECT create_provenance_mapping('personnel_level',
'personnel', 'classification');
SELECT p1.city,
sr_minmax(provenance(), 'personnel_level',
NULL::classification_level) AS min_clearance
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
ORDER BY p1.city;
Results: Nairobi requires restricted (Paul is the more-classified of
the two agents, and both must be accessed to confirm the pair). Beijing
requires secret (Ellen and Jing are both classified secret). Paris
requires confidential: the more-classified of David and Nancy is
confidential, the lowest such maximum among all Paris pairs, so
confidential clearance suffices to confirm at least one pair.
Step 5: Cities with Exactly One Agent (EXCEPT / Monus)
A city with a single agent is sensitive: whatever is traced back to the
agency’s station there (a report, an expense, a contact) points to one
person, where with two agents or more it could be any of them. Find the
cities with exactly one agent, as the cities with an agent minus those
with at least two, using EXCEPT:
SELECT city,
sr_formula(provenance(), 'personnel_name') AS formula
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
ORDER BY city;
Note
ProvSQL evaluates EXCEPT with the monus operator ⊖ of the
provenance semiring. Every city
appears in the result with a provenance formula. Whether that formula
evaluates to 𝟘 depends on the semiring it is evaluated in and on
the values given to the agents, not on the formula alone: with
polynomials over the agents’ names, no city’s formula is 𝟘;
with every agent present (true, in the Booleans), all three
cities are; in the min-max semiring of security levels, only
Beijing’s is. Nairobi’s formula
(Juma ⊕ Paul) ⊖ (Juma ⊗ Paul) reads: “at least one of Juma
or Paul is present, minus the event where both are.”
Step 6: Where-Provenance
Where-provenance tracks which column of which input row produced each output value. Enable it and re-run the shared-city query:
SET provsql.provenance = 'where';
SELECT p1.city,
where_provenance(provenance()) AS source
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
ORDER BY p1.city;
SET provsql.provenance = 'semiring';
Each city output value is traced back to the city column (column 4)
of the personnel table for every agent in that city. The notation
[personnel:〈token〉:4;...] shows the token and column index of each
contributing row; the trailing [] is the untracked source
column itself.
Seeing it in Studio: Where mode
ProvSQL Studio’s Where mode shows the same
answer interactively. Pick Where from the mode switcher at the
top and run the shared-city query without the two SET statements and
without the source column: Where mode sets provsql.provenance
itself. Then hover a city in the result: the city cells of every agent
in that city light up in the personnel table of the
Provenance-Tagged Relations panel, both agents for Nairobi and
all three for Paris. To return to the notebook, if you are following one,
pick Notebook from the same switcher.
Step 7: Assign Probabilities
Suppose the existence of each agent in the database is uncertain.
Assign each agent a probability equal to id / 10.0:
SELECT set_prob(provenance(), id / 10.0) FROM personnel;
Now Juma has probability 0.1, Paul 0.2, and so on up to Jing 0.7.
Step 8: Probability – Exact
Compute the exact probability that each city is a single-agent city:
SELECT city,
ROUND(probability_evaluate(provenance())::numeric, 4) AS prob
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
ORDER BY city;
Nairobi (agents with probabilities 0.1 and 0.2) has probability
0.1 × 0.8 + 0.9 × 0.2 = 0.26 of having exactly one agent. Beijing
(0.4 and 0.7) scores 0.54. Paris (0.3, 0.5, 0.6) gives 0.41.
Step 9: Probability – Monte Carlo
For larger circuits, exact evaluation can be expensive. Monte Carlo sampling gives an approximate answer:
SELECT city,
probability_evaluate(
provenance(), 'monte-carlo', '10000') AS prob
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
ORDER BY city;
With 10 000 samples the result is accurate to roughly ±0.01
(see Margin of error).
Results will vary slightly between runs due to sampling.
Compare the runtime against the exact method (\timing in psql,
or the per-query timing Studio displays).
Step 10: Probability – Knowledge Compiler
Note
This step uses an external knowledge compiler such as d4 or
dsharp, which must be on your PATH (or in a directory
listed in the provsql.tool_search_path GUC, see
the configuration chapter). The query below
guards the call with tool_available, so where no such
compiler is installed – in particular in the
Playground, which bundles none – it reports
that instead of failing, and you can read on.
A knowledge compiler converts the provenance circuit to a d-DNNF representation, which enables efficient exact probability evaluation on large circuits of specific forms:
SELECT city,
CASE WHEN tool_available('d4')
THEN ROUND(probability_evaluate(
provenance(), 'compilation', 'd4')::numeric, 4)::text
ELSE '(needs external compiler d4; unavailable here)'
END AS prob
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
ORDER BY city;
Compare the runtime (\timing in psql, or Studio’s per-query
timing) against the possible-worlds and Monte Carlo methods. On this
small example the external knowledge compiler is slower: invoking an
external process carries an overhead that only pays off on much larger
circuits.
Step 11: Visualise a Provenance Circuit
view_circuit renders the provenance circuit as an ASCII
box-art diagram using
graph-easy
(which must be on your PATH). The query guards the call with
tool_available, so without graph-easy – in particular in
the Playground, where Studio’s interactive
Circuit mode is the better view anyway – it returns a
message instead of failing:
SELECT city,
CASE WHEN tool_available('graph-easy')
THEN view_circuit(provenance(), 'personnel_name')
ELSE '(needs the graph-easy renderer; use Studio Circuit mode)'
END AS circuit
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
WHERE city = 'Nairobi';
The result shows the monus gate at the top, with Juma and
Paul as leaf inputs, rendered in box-art notation in the terminal.
Step 12: Export to XML
to_provxml serialises the provenance circuit to
PROV-XML, the W3C standard for
provenance interchange, which can be processed by any standard XML tool:
SELECT city,
to_provxml(provenance(), 'personnel_name') AS provxml
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
WHERE city = 'Nairobi';
Step 13: Large Circuit Benchmark
To compare the three probability algorithms at scale, create a synthetic 100 × 100 random-probability matrix and enable provenance tracking on it:
DROP TABLE IF EXISTS matrix CASCADE;
CREATE TABLE matrix AS
SELECT ones.n + 10 * tens.n AS x,
other.n + 10 * tens2.n AS y,
random() AS prob
FROM (VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)) ones(n),
(VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)) tens(n),
(VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)) other(n),
(VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)) tens2(n);
SELECT add_provenance('matrix');
SELECT set_prob(provenance(), prob) FROM matrix;
The last query returns one (empty) row per cell of the matrix, 10,000 in all.
Now run the same path query with each method in turn, timing each
(\timing in psql, or Studio’s per-query timing):
-- No method named: the exact probability, by the method expected to
-- be cheapest on each row's circuit
SET provsql.last_eval_method = '';
SELECT m1.x, m2.y,
probability_evaluate(provenance()) AS prob
FROM matrix m1, matrix m2
WHERE m2.x = m1.y AND m1.x > 90 AND m2.x > 90 AND m2.y > 90
GROUP BY m1.x, m2.y
ORDER BY m1.x, m2.y;
SHOW provsql.last_eval_method;
-- Exact enumeration over all possible worlds
SELECT m1.x, m2.y,
probability_evaluate(provenance(), 'possible-worlds') AS prob
FROM matrix m1, matrix m2
WHERE m2.x = m1.y AND m1.x > 90 AND m2.x > 90 AND m2.y > 90
GROUP BY m1.x, m2.y
ORDER BY m1.x, m2.y;
-- Monte Carlo sampling (9604 samples ≈ 1 % error at 95 % confidence)
SELECT m1.x, m2.y,
probability_evaluate(provenance(), 'monte-carlo', '9604') AS prob
FROM matrix m1, matrix m2
WHERE m2.x = m1.y AND m1.x > 90 AND m2.x > 90 AND m2.y > 90
GROUP BY m1.x, m2.y
ORDER BY m1.x, m2.y;
-- Tree-decomposition-based exact compilation (built-in, no external tool)
SELECT m1.x, m2.y,
probability_evaluate(provenance(), 'tree-decomposition') AS prob
FROM matrix m1, matrix m2
WHERE m2.x = m1.y AND m1.x > 90 AND m2.x > 90 AND m2.y > 90
GROUP BY m1.x, m2.y
ORDER BY m1.x, m2.y;
-- Knowledge compilation via external tool d4 (guarded with
-- tool_available, so it reports rather than fails where d4 is
-- absent, e.g. in the Playground)
SELECT m1.x, m2.y,
CASE WHEN tool_available('d4')
THEN probability_evaluate(provenance(), 'compilation', 'd4')::text
ELSE '(d4 unavailable here)'
END AS prob
FROM matrix m1, matrix m2
WHERE m2.x = m1.y AND m1.x > 90 AND m2.x > 90 AND m2.y > 90
GROUP BY m1.x, m2.y
ORDER BY m1.x, m2.y;
With no method named, probability_evaluate returns the exact
probability, and a cost-based chooser picks, for each circuit, the exact
method it expects to be cheapest (see Choosing a guarantee, not a method).
provsql.last_eval_method lists the
methods used since it was last cleared, so the query is preceded by
emptying it: here typically independent for some rows and d-tree
for others, since the rows’ circuits differ in shape. Studio
reports the method in its evaluation strip.
The Monte Carlo query uses 9604 samples, which gives roughly 1 %
additive error with 95 % confidence (by the formula
with
,
;
see Margin of error).
The 'tree-decomposition' method is exact and built into ProvSQL (no
external binary required). It is often the fastest exact method on simple
queries, but it fails on circuits with high treewidth; then fall back to
'compilation' or one of the other methods.
'independent' is the cheapest method of all – it multiplies and adds
marginal probabilities directly – but it applies only when the lineage is
independent, with no input tuple shared between the branches of an
or
. The path query above is not
independent (the self-join
m2.x = m1.y makes the two matrix lookups
share tuples), so probability_evaluate(provenance(), 'independent')
raises ProvSQL: Not an independent circuit instead of returning a wrong
answer. Use it on tuple-independent lineage.
Step 14: The Boolean Expression Behind a Token
sr_boolexpr returns the abstract Boolean formula of a provenance
circuit. Without a mapping it uses internal variable names x0,
x1…; with an optional second argument naming a provenance
mapping table the leaves are labelled by the mapping’s value
column instead.
SELECT city, sr_boolexpr(provenance()) AS boolexpr
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
WHERE city = 'Nairobi';
For Nairobi, the result is the circuit (Juma ⊕ Paul) ⊖ (Juma ⊗ Paul)
from Step 5 read as a Boolean function (⊕ as ∨, ⊗ as ∧,
⊖ as ∧¬) and rendered as a formula over anonymous variables.
Unlike with sr_formula, the provenance mapping is optional.
Step 15: Programmatic Circuit Inspection
What view_circuit renders, you can also walk programmatically
with the low-level circuit API. Capture Nairobi’s monus token first:
CREATE TEMP TABLE nairobi_token AS
SELECT provenance() AS prov
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
WHERE city = 'Nairobi';
get_nb_gates reports how many gates the current database’s
provenance circuit holds:
SELECT get_nb_gates();
get_gate_type and get_children give a single-step view
of the gate structure: they return the operator and direct children of a
gate.
SELECT get_gate_type(prov) AS root_type,
get_children(prov) AS root_children
FROM nairobi_token;
For Nairobi, the root is a monus gate with two children: the ⊕
sub-circuit (Juma ⊕ Paul) and the ⊗ sub-circuit (Juma ⊗ Paul).
Recurse to inspect the children:
SELECT (get_children(prov))[1] AS plus_token,
get_gate_type((get_children(prov))[1]) AS plus_type,
get_children((get_children(prov))[1]) AS plus_children
FROM nairobi_token;
The leaves of the circuit are input gates that originate from the
personnel table. identify_token performs a reverse lookup,
returning the table and column count for an input token:
SELECT identify_token(child) AS source
FROM nairobi_token, unnest(get_children((get_children(prov))[1])) AS child;
Both leaves resolve to (personnel, 5): the personnel table with
its five non-provenance columns (id, name, position, city
and classification).
Step 16: Revising a Probability
New intelligence says Juma is almost certainly a real agent: his
probability should be 0.9, not 0.1. A probability is written once:
set_prob refuses a different value for a token that already has
one, and its error message points to replace_input, which gives
the row a fresh input gate with the new probability:
UPDATE personnel SET provsql = replace_input(provsql, 0.9)
WHERE name = 'Juma';
Queries from now on see the new value. Rerunning the query of Step 8,
Nairobi’s single-agent probability becomes
0.9 × 0.8 + 0.1 × 0.2 = 0.74, and the other cities are unchanged:
SELECT city,
ROUND(probability_evaluate(provenance())::numeric, 4) AS prob
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
ORDER BY city;
A result computed before the change keeps the old probability: the token
saved in Step 15 was built from Juma’s old input gate, which is still in
the circuit, and it still evaluates to 0.26:
SELECT ROUND(probability_evaluate(prov)::numeric, 4) AS prob
FROM nairobi_token;
Step 17: Clearance Levels of a Negation
An analyst runs the query of Step 5 seeing only the agents whose records
are classified at or below the analyst’s clearance. At which levels does
a city look single-agent?
sr_minmax (Step 4) cannot answer this for a query with
EXCEPT: it returns a single level, read as “from that level up”, but
an analyst with more clearance sees more agents and may no longer see
the city as single-agent. sr_clearance returns the set of
levels at which the query returns the row, with negation read exactly.
Juma’s row has a new token since Step 16, so the mapping is rebuilt
first:
DROP TABLE IF EXISTS personnel_level;
SELECT create_provenance_mapping('personnel_level',
'personnel', 'classification');
SELECT city,
sr_clearance(provenance(), 'personnel_level',
NULL::classification_level) AS levels,
visible_at(provenance(), 'personnel_level', 'unclassified')
AS at_unclassified,
(clearance_settling(provenance(), 'personnel_level',
NULL::classification_level)).*
FROM (
SELECT DISTINCT city FROM personnel
EXCEPT
SELECT p1.city
FROM personnel p1
JOIN personnel p2 ON p1.city = p2.city AND p1.id < p2.id
GROUP BY p1.city
) t
ORDER BY city;
Nairobi looks single-agent only at unclassified, where Paul is
hidden, and Paris only at restricted, where Nancy is the one agent
visible; Beijing never does. sr_minmax would answer
unclassified and restricted, suggesting every higher level too.
visible_at tests a single level, given as text, and
clearance_settling gives the level from which the answer no
longer changes (restricted for Nairobi, confidential for Paris)
and that answer at the top level.
Step 18: What Allied Services Conclude
The agency shares some personnel files with allied services. Each ally sees some of the agents, and unlike clearance levels, the allies are not ordered. Record, for each agent, the allies the file is shared with, in a new column whose type is an array over an enum of the allies:
DROP TABLE IF EXISTS personnel_shared;
ALTER TABLE personnel DROP COLUMN IF EXISTS shared_with;
DROP TYPE IF EXISTS ally;
CREATE TYPE ally AS ENUM ('bnd', 'dgse', 'mi6');
ALTER TABLE personnel ADD COLUMN shared_with ally[];
UPDATE personnel
SET shared_with = CASE name WHEN 'Juma' THEN '{mi6,dgse}'
WHEN 'Paul' THEN '{mi6}'
WHEN 'David' THEN '{dgse,bnd}'
WHEN 'Ellen' THEN '{bnd}'
WHEN 'Aaheli' THEN '{}'
WHEN 'Nancy' THEN '{mi6,dgse,bnd}'
WHEN 'Jing' THEN '{dgse}' END::ally[];
SELECT name, city, shared_with FROM personnel ORDER BY id;
How many agents does each ally see in each city? sr_subset reads
the labels of an enum as worlds, here one per ally, through a mapping that
gives the allies of each row; it returns the set of allies whose view
returns the result row. Build the mapping from the new column:
SELECT create_provenance_mapping('personnel_shared',
'personnel', 'shared_with');
Grouping the cities by their number of agents gives one row per number a
city can show, and sr_subset the allies to which it shows that
number:
SELECT city, n AS agents,
sr_subset(provenance(), 'personnel_shared', NULL::ally) AS allies
FROM (SELECT city, count(*) AS n FROM personnel GROUP BY city) t
GROUP BY city, n
ORDER BY city, agents DESC;
No ally sees all of Paris: the BND and the DGSE see two of its agents, MI6 only Nancy. Paris thus looks single-agent to MI6, Nairobi to the DGSE, and Beijing to both the BND and the DGSE.