ilinti

2025-12-10

finding inconsistencies in opinion graphs

How consistent are your beliefs? Most people feel fairly consistent, but when you actually map out what you believe and how those beliefs relate to each other, contradictions show up faster than you'd expect. ilinti is a small web app that tries to make those inconsistencies visible.

I previously called this project Mindflayer, the D&D creatures that manipulate opinions and thoughts, I was playing some Baldurs Gate 3 while developing this. It fit the concept, but felt a bit overdramatic and also off-putting for what is essentially a graph calculator. The current name comes from the Turkish word ilinti, meaning relation, which i find to be more suiting.

Project Page

visit the project page and sign up

How it works

You start by picking a topic and entering a set of statements. Each statement gets assigned a value: +1 if you believe it's true, -1 if you believe it's false. Something like:

  • "Economic growth reduces poverty" -> true
  • "National wealth inequality is increasing" -> true
  • "Global wealth inequality is increasing" -> false

Then the app begins asking you about pairs of statements. For each pair, you say whether you think there's a relation between them and what kind:

  • positive (one being true makes the other more likely to be true)
  • negative (one being true makes the other less likely)
  • none

Over time your answers build up a directed, weighted graph where nodes are statements and edges are the relations you've asserted.

Once enough connections exist, the app calculates a dissonance score for your graph. A high score means there are edges in the graph where the relation type and the truth values you assigned are in conflict. If you believe A is true and B is true but also said A has a negative effect on B, that's a contradiction. The dissonance score increases.

The app also has a smarter mode beyond random pairs: instead of picking two statements at random, it finds the pair that would have the highest impact on your dissonance score if connected and asks you about that one first. This makes the graph more informative faster.

The graph math

Each node has a type (+1 or -1) and each edge has a relation type (+1, -1, or 0). Dissonance for a single edge (i, k) is:

edge_relation(i,k) * node_type(i) * node_type(k)

If this product is negative, the edge is inconsistent with the beliefs at its endpoints. The overall dissonance is a weighted sum of these terms across all edges.

The weights factor in two things. First, node relevance: nodes that appear on more paths through the graph are more central, so inconsistencies involving them count more. This is computed by summing powers of the adjacency matrix, the same idea as betweenness centrality. Second, component density: inconsistencies in a densely-connected part of the graph carry a lower weight, since a dense component is expected to have some tension. Sparse connections are more surprising and get weighted up.

The statement suggestion logic runs the full dissonance calculation, then walks through all edges ordered by their contribution weight and looks for the first pair that doesn't have a relation recorded yet. That's the pair it asks you about next.

When exploring your believes you might encounter interesting effects where a node that randomly received more connections in the beginning will be more relevant later on (I try to mitigate this with the component density). Adding a new connection to it, provides the most new indirect relation to the other connected nodes.

example opinion graph

The C++ addon

The dissonance calculation and statement suggestion both need to build and traverse an adjacency matrix, query the database for node and edge data and do some linear algebra. I implemented this as a native Node.js addon using the N-API, so it can be called directly from TypeScript as a regular module:

const analyzer = require("../../build/Release/analyzer")
const result = analyzer.dissonance(user_id, topic_id);

On the C++ side, the addon uses Eigen for the matrix operations, libpqxx for talking to PostgreSQL directly (bypassing the Node.js layer entirely) and nlohmann/json for serializing results back to JavaScript as JSON strings.

Was the C++ necessary? No. The TypeScript version of the same calculation already existed and was fast enough. But writing a Node.js native addon was something I hadn't done before and seeing a C++ function be called transparently from TypeScript is satisfying. The build setup with node-gyp and vcpkg for dependency management was probably the most painful part.

The rest of the stack

The frontend is Vue 3 with a graph visualization component that renders your opinion graph as a force-directed diagram using the adjacency matrix returned by the backend. Seeing the structure of your beliefs laid out visually, makes the abstract score concrete.

The backend is Express with TypeScript. User data is stored in PostgreSQL with four tables: users, topics, statements and statement_pairs. Auth uses JWT tokens, registration hashes passwords with bcrypt, login checks against the hash and returns a token and all data endpoints require the token via middleware.

The database schema is simple by design. Topics belong to users. Statements belong to topics. Statement pairs reference two statements and record a relation type. The uniqueness constraint on (first_statement, second_statement) means submitting a relation for a pair you've already answered updates it rather than creating a duplicate.

What I got out of it

The most interesting part was working out the dissonance metric and the suggestion algorithm. Getting the weighting right so that the score is actually meaningful and not just dominated by the most-connected nodes, required several iterations. The density weighting in particular was something I added after noticing the score was inflated for graphs where a single cluster / connected-component was dominating the suggested pairs.

The C++ addon was a side trip that took longer than expected but was worth doing once. N-API is well-documented, but the toolchain setup (binding.gyp, node-gyp rebuild, getting Eigen and pqxx in via vcpkg) ate a lot of time before the first function could be called. Now I would probably just do everything as a full-stack Rust application.

This was also the first time I deployed a full web app end-to-end: separate frontend and backend, a real database with user accounts, bcrypt password hashing, JWT auth, and prepared statements throughout the backend to avoid SQL injection. A lot of it is probably overkill for a small personal project. You don't need full user management for something with three users... or maybe just one. But going through the pain of doing it properly was the point. It's a different kind of learning than building something that only runs locally.

The project is live if you want to try it, registration is open.