지난 글 RDF Reification을 다시 보기: RDF 1.2와 Wirelog Compound Term에서는 compound term을 RDF triple term과 비교했습니다. 핵심 질문은 “트리플 자체에 대해 말하려면 어떻게 해야 하는가”였습니다. 이번 글은 조금 더 wirelog 내부 쪽으로 들어갑니다.

질문은 이렇습니다.

Datalog 엔진에 왜 compound term이 필요한가?

flat tuple만으로도 Datalog는 충분히 강력합니다. relation은 원래 여러 column을 가질 수 있고, join으로 구조를 표현할 수 있습니다. 그런데도 wirelogmetadata(Level, Ts, Host, Risk) 같은 compound term을 넣은 이유는 단순한 문법 편의가 아닙니다. 제가 보고 있는 방향은 “서로 함께 움직여야 하는 값의 묶음”을 엔진이 직접 이해하게 만드는 것입니다.

시간이 붙은 주장, 출처가 다른 진술, 서로 충돌하는 타입 정보는 대개 scalar 하나로 끝나지 않습니다. 값, 시점, 출처, 타입, 신뢰도 같은 것들이 한 덩어리로 움직입니다. compound term은 바로 그 덩어리에 이름을 붙입니다.

flat tuple로도 표현은 된다

예를 들어 서버 상태 이벤트를 다룬다고 해보겠습니다.

.decl event(id: symbol, level: symbol, ts: int64, host: symbol, risk: int64)
.decl hot(id: symbol, host: symbol)

hot(ID, Host) :-
    event(ID, "error", Ts, Host, Risk),
    Risk > 80.

문제없이 동작하는 모델입니다. 하지만 시간이 지나면 event relation이 점점 커집니다.

.decl event(
    id: symbol,
    level: symbol,
    ts: int64,
    host: symbol,
    risk: int64,
    source: symbol,
    schema_version: int64,
    confidence: int64
)

이제 어떤 column들이 “payload”이고, 어떤 column들이 “provenance”인지 사람이 계속 기억해야 합니다. 규칙도 점점 position에 민감해집니다.

hot(ID, Host) :-
    event(ID, "error", Ts, Host, Risk, Source, Version, Confidence),
    Risk > 80,
    Confidence > 70.

여기서 level, ts, host, risk는 사실 하나의 payload입니다. source, schema_version, confidence는 그 payload가 어디에서 어떻게 왔는지를 설명하는 context입니다. flat tuple은 이 차이를 표현하지 못합니다. 단지 column 순서만 있을 뿐입니다.

compound term을 쓰면 의도가 드러납니다.

.decl event(id: symbol, payload: metadata/4 side, ctx: context/3 side)
.decl hot(id: symbol, host: symbol)

hot(ID, Host) :-
    event(ID, metadata("error", Ts, Host, Risk), context(Source, Version, Confidence)),
    Risk > 80,
    Confidence > 70.

metadata(...)는 이벤트의 내용입니다. context(...)는 그 이벤트의 맥락입니다. 둘 다 여러 값을 갖지만, 각각 하나의 구조값으로 취급됩니다.

compound term은 구조화된 값이다

wirelog에서 compound term은 functor와 arity를 갖습니다.

point(3, 7)                 -> functor = point, arity = 2
metadata(level, ts, h, r)   -> functor = metadata, arity = 4
context(src, ver, conf)     -> functor = context, arity = 3

선언은 relation column의 type 자리에 씁니다.

.decl event(id: symbol, payload: metadata/4 side)
.decl point_cloud(idx: int64, pt: point/3 inline)

여기서 중요한 것은 metadata/4가 단순히 “int64 column 네 개”가 아니라는 점입니다. metadata라는 이름과 arity 4가 column schema에 붙습니다. body에서는 이 구조를 패턴으로 다시 꺼낼 수 있습니다.

.decl event(id: symbol, payload: metadata/4 side)
.decl risky(id: symbol, host: symbol)

risky(ID, Host) :-
    event(ID, metadata(Level, Ts, Host, Risk)),
    Risk > 80.

이 규칙은 “event의 payload가 metadata 형태일 때, 네 번째 인자인 Risk를 보고 위험한 이벤트를 찾는다”는 뜻입니다.

이것은 일반 Prolog의 완전한 unification과는 다릅니다. 현재 wirelog compound term은 주로 body position에서 declared compound column을 구조분해하는 패턴입니다. rule head에서 새 compound를 구성하는 일반적인 Prolog식 term construction을 목표로 한 기능은 아닙니다.

/* 현재 의도한 사용 방식: body에서 구조분해 */
args(ID, Level, Ts, Host, Risk) :-
    event(ID, metadata(Level, Ts, Host, Risk)).

/* head에서 새 compound를 만드는 일반 term construction은 별도 문제 */
event(ID, metadata(Level, Ts, Host, Risk)) :-
    raw(ID, Level, Ts, Host, Risk).

이 제약은 오히려 wirelog의 성격을 잘 보여줍니다. wirelog는 Prolog 인터프리터가 아니라 columnar Datalog 엔진입니다. compound term은 무한한 term tree를 탐색하기 위한 기능이 아니라, typed columnar storage 안에서 구조화된 값을 다루기 위한 기능입니다.

inline tier와 side-relation tier

wirelog은 compound column을 두 방식으로 저장합니다.

inline tier:
  compound 인자들을 parent relation의 물리 column으로 펼쳐 저장

side-relation tier:
  parent relation에는 64-bit handle만 저장
  실제 인자는 __compound_<functor>_<arity> relation에 저장

작고 얕은 compound는 inline으로 둘 수 있습니다.

.decl edge(src: int64, label: pair/2 inline, dst: int64)

논리 schema는 세 column입니다.

edge(src, label, dst)

하지만 물리 layout은 이렇게 됩니다.

[src] [label_arg0] [label_arg1] [dst]

pair(a, b)가 별도 allocation 없이 parent relation 안에 그대로 펼쳐집니다. filter나 join에서 compound argument를 읽을 때도 결국 columnar access입니다.

반대로 큰 compound나 중첩 compound는 side relation이 맞습니다.

.decl event(id: symbol, payload: metadata/4 side)

이때 parent relation에는 handle만 들어갑니다.

event(id, payload_handle)

그리고 자동 생성 relation이 생깁니다.

__compound_metadata_4(handle, arg0, arg1, arg2, arg3)

body에서 다음 패턴을 쓰면:

event(ID, metadata(Level, Ts, Host, Risk))

엔진은 내부적으로 parent의 payload_handle__compound_metadata_4.handle을 join하고, arg0..arg3Level, Ts, Host, Risk에 bind합니다.

구조를 그림으로 보면 이렇습니다.

graph LR
    A["event<br/>(id, payload_handle)"] -->|payload_handle| B["__compound_metadata_4"]
    B --> C["arg0: level"]
    B --> D["arg1: timestamp"]
    B --> E["arg2: host"]
    B --> F["arg3: risk"]

inline은 빠르고 단순합니다. side relation은 indirection이 있지만, 큰 구조와 중첩 구조를 다룰 수 있습니다. 둘 다 Z-set과 timestamp layer 위에서 움직입니다. 이 점이 중요합니다. compound term은 payload일 뿐이고, insert/remove에 따른 multiplicity와 시간 진행은 기존 differential-style substrate가 맡습니다.

PyreWire에서는 어떻게 보이는가

PyreWirewirelog의 Python binding입니다. compound term과 관련해 크게 두 가지를 제공합니다.

첫째, schema introspection에서 compound metadata를 볼 수 있습니다.

from pyrewire import Program

src = """
.decl event(id: int64, payload: point/2 inline)
.decl meta(id: int64, m: metadata/4 side)
"""

with Program.from_string(src) as p:
    for rel in ["event", "meta"]:
        schema = p.schema(rel)
        print(rel)
        for col in schema.columns:
            print(col.name, col.type, col.compound_kind, col.compound_arity)

로컬에서 확인하면 event.payload는 inline compound, meta.m은 side compound로 노출됩니다. Python 쪽에서도 “이 column이 단순 scalar인지, compound인지”를 알 수 있다는 뜻입니다.

둘째, session-local compound handle을 만들 수 있습니다.

from pyrewire import EasySession, ColumnType, CompoundArg

SRC = """
.decl x(a: int32)
"""

with EasySession(SRC) as s:
    pair = s.make_compound("pair", [
        CompoundArg(ColumnType.INT32, 1),
        CompoundArg(ColumnType.INT32, 2),
    ])
    print(pair.handle)

여기서 pair는 Python object이지만, 실제 값은 C 쪽 session-local compound arena에 있는 handle입니다. 그래서 session이 닫히면 handle은 더 이상 유효하지 않습니다. PyreWire의 Compound wrapper는 이 점을 Python 예외로 드러냅니다.

s = EasySession(".decl x(a: int32)\n")
c = s.make_compound("pair", [CompoundArg(ColumnType.INT32, 1)])
s.close()

c.handle   # ValueError

이 동작은 불편함이 아니라 안전장치입니다. compound handle은 전역 ID가 아닙니다. 특정 session의 arena, epoch, offset에 묶인 값입니다. Python 객체가 그 lifetime을 연장해주면 오히려 C 쪽 계약이 흐려집니다.

PyreWire 예제: 시간은 step으로 흐른다

compound term을 시간과 연결해 생각하려면 먼저 PyreWire의 시간 모델을 봐야 합니다. examples/11_time_evolution.py는 가장 작은 예제입니다.

from pyrewire import EasySession

SRC = """
.decl event(id: symbol, kind: symbol)
.decl alert(id: symbol)
alert(ID) :- event(ID, "error").
"""

with EasySession(SRC) as s:
    s.insert_sym("event", "e1", "error")
    s.insert_sym("event", "e2", "info")
    s.insert_sym("event", "e3", "error")
    print(s.step())

    s.insert_sym("event", "e4", "info")
    print(s.step())

    s.insert_sym("event", "e5", "error")
    print(s.step())

제가 로컬에서 실행한 결과는 다음과 같습니다.

== epoch1_errors_emerge ==
+alert('e1',)
+alert('e3',)
== epoch2_only_info ==
== epoch3_one_more_alert ==
+alert('e5',)

step() 하나가 discrete epoch입니다. 중요한 점은 step()이 전체 snapshot을 다시 주는 것이 아니라, 그 epoch에서 새로 나온 delta만 준다는 것입니다.

이제 payload를 compound로 바꿔 생각해볼 수 있습니다.

.decl event(id: symbol, payload: observed/2 inline)
.decl alert(id: symbol)

alert(ID) :-
    event(ID, observed(Ts, "error")).

여기서 observed(Ts, "error")는 “어떤 시점에 error가 관측되었다”는 구조값입니다. 단순히 event(id, ts, kind)라고 써도 되지만, observed(...)라는 이름을 붙이면 규칙이 다루는 단위가 명확해집니다.

시간을 relation 바깥의 실행 epoch으로 볼 수도 있고, relation 안의 timestamp field로 볼 수도 있습니다. 실제 시스템에서는 둘 다 필요합니다.

의미
step() epoch 엔진에 변경이 들어오고 전파되는 계산 시점
compound 내부 Ts 도메인 사건이 발생했거나 관측된 시점

둘을 혼동하면 안 됩니다. step()은 “언제 계산했는가”이고, observed(Ts, V)Ts는 “언제 그런 일이 있었다고 주장하는가”입니다.

Last-writer-wins와 compound term

examples/06_timestamp_lww.py는 timestamp를 사용해 최신 값을 고르는 예제입니다.

.decl update(id: int32, ts: int64, value: int32)
.decl latest_ts(id: int32, ts: int64)
.decl latest(id: int32, ts: int64, value: int32)

latest_ts(Id, max(Ts)) :- update(Id, Ts, _).
latest(Id, Ts, V) :- latest_ts(Id, Ts), update(Id, Ts, V).

로컬 실행 결과는 다음과 같았습니다.

== latest_ts ==
(1, 2000)
(2, 800)
== latest ==
(1, 2000, 200)
(2, 800, 40)

같은 생각을 compound term으로 표현하면 다음처럼 바꿀 수 있습니다.

.decl update(id: int32, obs: observed/2 inline)
.decl latest_ts(id: int32, ts: int64)
.decl latest(id: int32, ts: int64, value: int32)

latest_ts(Id, max(Ts)) :-
    update(Id, observed(Ts, _)).

latest(Id, Ts, V) :-
    latest_ts(Id, Ts),
    update(Id, observed(Ts, V)).

flat tuple 버전과 논리적으로 같은 일을 합니다. 차이는 TsVobserved(Ts, V)라는 하나의 구조로 묶였다는 점입니다. 나중에 관측 source나 confidence가 들어오면 구조를 확장할 수 있습니다.

.decl update(id: int32, obs: observed/4 side)

latest_ts(Id, max(Ts)) :-
    update(Id, observed(Ts, _, _, _)).

latest(Id, Ts, V, Source, Confidence) :-
    latest_ts(Id, Ts),
    update(Id, observed(Ts, V, Source, Confidence)).

이 모델에서 “최신값”은 단지 최신 scalar가 아닙니다. 최신 시점에 관측된 값과 출처와 신뢰도가 한꺼번에 따라옵니다.

모순 관계: 같은 시점의 다른 주장

compound term이 더 빛나는 곳은 모순 탐지입니다. 모순은 보통 값 하나만 보고 판단할 수 없습니다. 같은 entity, 같은 attribute, 같은 시점, 다른 source, 다른 value가 함께 있어야 합니다.

.decl claim(entity: symbol, c: claim/5 side)
.decl contradiction(entity: symbol, attr: symbol, v1: symbol, v2: symbol, ts: int64)

contradiction(E, A, V1, V2, Ts) :-
    claim(E, claim(A, V1, Ty1, Ts, Src1)),
    claim(E, claim(A, V2, Ty2, Ts, Src2)),
    V1 != V2,
    Src1 != Src2.

예를 들어 다음 두 주장이 들어왔다고 합시다.

claim("server-1", claim("status", "up",   "enum", 1000, "monitor-a"))
claim("server-1", claim("status", "down", "enum", 1000, "monitor-b"))

그러면 도출하고 싶은 것은 다음입니다.

contradiction("server-1", "status", "up", "down", 1000)

여기서 compound term의 역할은 분명합니다. claim(A, V, Ty, Ts, Src)는 하나의 주장입니다. attribute, value, type, timestamp, source가 따로 떠다니지 않습니다. 모순 규칙은 같은 entity에 대해 두 claim을 나란히 놓고 비교합니다.

flat tuple로도 가능합니다.

.decl claim(entity: symbol, attr: symbol, value: symbol, ty: symbol, ts: int64, source: symbol)

하지만 이 경우 attr, value, ty, ts, source가 하나의 claim이라는 사실은 relation 이름과 column 순서에 암묵적으로만 들어 있습니다. compound term은 그 claim boundary를 코드에 드러냅니다.

모순의 종류가 늘어날수록 이 차이는 커집니다.

.decl opposite_value(attr: symbol, a: symbol, b: symbol)
.decl semantic_contradiction(entity: symbol, attr: symbol, a: symbol, b: symbol, ts: int64)

semantic_contradiction(E, A, V1, V2, Ts) :-
    claim(E, claim(A, V1, _, Ts, Src1)),
    claim(E, claim(A, V2, _, Ts, Src2)),
    opposite_value(A, V1, V2),
    Src1 != Src2.

단순히 V1 != V2가 아니라, 특정 attribute에서만 반대 관계로 정의된 값들을 모순으로 볼 수 있습니다. 예를 들어 status에서는 updown이 반대지만, owner에서는 두 source가 서로 다른 이름을 말한다고 해서 곧바로 논리적 모순이라고 볼 수는 없습니다.

타입 충돌: 값이 아니라 claim의 타입이 틀린다

타입 충돌도 compound term과 잘 맞습니다. 타입 오류는 보통 “값 하나가 이상하다”가 아니라 “어떤 attribute에 어떤 값이 어떤 타입으로 들어왔다”는 claim 전체의 문제입니다.

.decl observed(entity: symbol, av: attr_value/3 inline)
.decl expected_type(attr: symbol, ty: symbol)
.decl actual_type(value: symbol, ty: symbol)
.decl type_conflict(entity: symbol, attr: symbol, value: symbol, expected: symbol, actual: symbol, ts: int64)

type_conflict(E, A, V, Expected, Actual, Ts) :-
    observed(E, attr_value(A, V, Ts)),
    expected_type(A, Expected),
    actual_type(V, Actual),
    Expected != Actual.

예를 들어 다음 사실이 있다고 합시다.

observed("user-7", attr_value("age", "twenty", 3000))
expected_type("age", "int")
actual_type("twenty", "string")

도출되는 충돌은 다음입니다.

type_conflict("user-7", "age", "twenty", "int", "string", 3000)

attr_value(A, V, Ts)는 “attribute A가 시점 Ts에 값 V로 관측되었다”는 구조입니다. 타입 충돌 규칙은 그 구조를 꺼내 expected_typeactual_type을 비교합니다.

실제 시스템에서는 actual_type(value, ty)도 단순 relation이 아닐 수 있습니다. JSON parser, schema registry, LLM extractor, external API가 각자 타입을 말할 수 있습니다. 그러면 claim 구조를 조금 더 풍부하게 잡습니다.

.decl typed_claim(entity: symbol, c: typed_claim/6 side)
.decl expected_type(attr: symbol, ty: symbol)
.decl type_conflict(entity: symbol, attr: symbol, value: symbol, expected: symbol, actual: symbol, source: symbol, ts: int64)

type_conflict(E, A, V, Expected, Actual, Src, Ts) :-
    typed_claim(E, typed_claim(A, V, Actual, Ts, Src, Confidence)),
    expected_type(A, Expected),
    Expected != Actual.

이제 타입 충돌은 “값 V가 틀렸다”가 아니라 “source Src가 시점 Ts에 confidence를 갖고 내놓은 typed claim이 expected schema와 맞지 않는다”가 됩니다. 이 정도가 되면 flat tuple보다 compound term이 훨씬 읽기 쉽습니다.

시간, 모순, 타입 충돌을 하나로 묶기

조금 더 통합된 모델을 써보면 다음과 같습니다.

.decl fact(entity: symbol, c: claim/5 side)
.decl latest_ts(entity: symbol, attr: symbol, ts: int64)
.decl current(entity: symbol, attr: symbol, value: symbol, ty: symbol, source: symbol)

.decl expected_type(attr: symbol, ty: symbol)
.decl type_conflict(entity: symbol, attr: symbol, value: symbol, expected: symbol, actual: symbol)
.decl contradiction(entity: symbol, attr: symbol, v1: symbol, v2: symbol, ts: int64)

latest_ts(E, A, max(Ts)) :-
    fact(E, claim(A, V, Ty, Ts, Src)).

current(E, A, V, Ty, Src) :-
    latest_ts(E, A, Ts),
    fact(E, claim(A, V, Ty, Ts, Src)).

type_conflict(E, A, V, Expected, Actual) :-
    current(E, A, V, Actual, _),
    expected_type(A, Expected),
    Expected != Actual.

contradiction(E, A, V1, V2, Ts) :-
    fact(E, claim(A, V1, Ty1, Ts, Src1)),
    fact(E, claim(A, V2, Ty2, Ts, Src2)),
    V1 != V2,
    Src1 != Src2.

이 프로그램에서 claim(A, V, Ty, Ts, Src)는 하나의 semantic atom입니다.

attribute A가
value V라고
type Ty로
timestamp Ts에
source Src에 의해 주장되었다

그 위에 세 가지 규칙을 얹습니다.

규칙 의미
latest_ts entity + attribute별 최신 시점
current 최신 시점의 현재 claim
type_conflict 현재 claim의 타입이 expected type과 다름
contradiction 같은 시점에 다른 source가 서로 다른 값을 주장

compound term이 없다면 이 모든 field가 relation의 top-level column으로 올라옵니다. 물론 실행은 됩니다. 하지만 규칙을 읽는 사람이 어떤 column들이 하나의 claim을 이루는지 계속 머릿속으로 묶어야 합니다. compound term은 그 묶음을 코드 안에 남깁니다.

왜 이것이 중요한가

compound term이 중요한 이유를 한 문장으로 줄이면 이렇습니다.

엔진이 다루는 단위와 사람이 생각하는 단위를 맞춘다.

Datalog relation은 기본적으로 flat합니다. 이 flat함은 장점입니다. join, projection, filter, aggregation이 단순해지고, columnar execution에도 잘 맞습니다. 하지만 지식 표현에서는 “같이 움직이는 값의 묶음”이 자주 등장합니다.

  • 주장은 값, 타입, 시점, 출처를 함께 가진다.
  • 관측은 값과 관측 시점을 함께 가진다.
  • 이벤트 payload는 여러 field를 가지지만 하나의 payload다.
  • RDF triple에 대한 메타데이터는 triple 자체와 context를 함께 다룬다.
  • 타입 충돌은 값 하나가 아니라 attribute-value-type claim의 문제다.

compound term은 이 묶음을 relation 바깥의 객체 모델로 밀어내지 않습니다. Datalog relation 안에 둡니다. 그러면서도 rule body에서는 claim(A, V, Ty, Ts, Src)처럼 구조분해할 수 있게 합니다.

이 점이 중요합니다. 구조를 Python object로만 들고 있으면 Datalog 엔진은 그 내부를 모릅니다. 반대로 모든 구조를 flat column으로만 펼치면 엔진은 빠르게 처리할 수 있지만, 사람이 읽는 의미 경계가 흐려집니다. compound term은 둘 사이의 절충입니다.

PyreWire에서 조심할 점

현재 PyreWire의 compound API는 “Python에서 임의의 compound value를 만들어 relation에 자연스럽게 넣는 완전한 object mapper”가 아닙니다. C의 side-relation handle API를 Python에서 안전하게 감싸는 층에 가깝습니다.

문자열 인자를 compound에 넣을 때도 문자열 자체가 아니라 intern id를 넣습니다.

host = session.intern("host-a")

payload = session.make_compound("metadata", [
    CompoundArg(ColumnType.STRING, session.intern("error")),
    CompoundArg(ColumnType.INT64, 1000),
    CompoundArg(ColumnType.STRING, host),
    CompoundArg(ColumnType.INT64, 90),
])

그리고 이 handle은 session-local입니다.

session.insert("event", ["e1", payload.handle])

session이 닫히면 payload.handle은 더 이상 의미가 없습니다. 저장 가능한 영속 ID가 아니라, 현재 session 안에서 side relation row를 가리키는 opaque handle입니다.

따라서 PyreWire를 쓸 때는 두 층을 구분해야 합니다.

역할
Datalog source .decl event(payload: metadata/4 side)처럼 compound schema 선언
PyreWire runtime make_compound()로 session-local handle 생성
rule body event(ID, metadata(Level, Ts, Host, Risk))로 구조분해
Python object handle lifetime을 안전하게 감싸는 wrapper

이 경계가 분명해야 합니다. compound term은 Python 객체 시스템의 편의 기능이 아니라, wirelog 실행 모델 안의 구조화된 값입니다.

마치며

compound term을 처음 보면 “Datalog에 구조체를 넣은 것” 정도로 보일 수 있습니다. 틀린 말은 아니지만, 조금 부족합니다. 제가 보기에는 compound term의 핵심은 진술의 경계를 표현하는 데 있습니다.

claim(A, V, Ty, Ts, Src)라고 쓰는 순간, 다섯 개의 scalar는 하나의 주장으로 묶입니다. observed(Ts, V)라고 쓰는 순간, 시점과 값은 하나의 관측으로 묶입니다. metadata(Level, Ts, Host, Risk)라고 쓰는 순간, 이벤트 payload를 여러 top-level column으로 풀어 쓰지 않아도 됩니다.

시간을 다룰 때는 “계산 epoch”과 “도메인 timestamp”를 분리할 수 있습니다. 모순을 다룰 때는 같은 시점의 서로 다른 claim을 비교할 수 있습니다. 타입 충돌을 다룰 때는 값 하나가 아니라 typed claim 전체를 검사할 수 있습니다.

이것이 compound term을 넣은 이유입니다. 더 복잡한 문법을 원해서가 아니라, 복잡한 사실의 경계를 잃지 않기 위해서입니다.


더 알아보기

관련 글