Open Source Algorithms

Article Search

Last Updated: September 26th, 2025

Article recommendations are based on proximity to a user in our social graph database, which can be viewed on your Explore Page on Aemula.

While our graph database runs on an Aemula-controlled server for efficient querying, the entire database can be recreated from publicly-accessible data at any time.

The graph is created by querying the Aemula subgraph, which is a publicly queryable GraphQL-indexed subgraph of the activity generated from the Aemula Smart Contract.

For suggested improvements or requested changes, email community@aemula.com.

We are in process of setting up the community governance of the open source algorithms, allowing community members to make pull requests of the source code from Github and propose changes for vote by the community.


Function explained with comments throughout:

// Article Search:
// Discovers relevant and recent articles near the user for consideration in ranking
export const improvedArticleRec = async (account, candidateLimit = 60) => {
    // Create a session to connect to our Neo4j graph database
    const session = driver.session();
    // Ensure the user's account is not checksummed to align with database schema
    const lowerAddress = account.toLowerCase();

    // Weight to control how much we emphasize opinions of a user's neighbors
    // To promote relevancy, we want to recommend articles that similar users have supported
    // We can determine similarity by looking at a user's neighbors, or their close connections in the graph
    // An articles "Near Support" is the support votes received from a user's immediate neighbors
    const NEAR_SUPPORT_WEIGHT = 2.0;
    // An articles "Global Support" counts all support votes from all users
    const GLOBAL_SUPPORT_WEIGHT = 1.0;

    try {
        // Database Search Query:
        // First MATCH the user's account to their node in our graph
        // Set out to discover 60 articles to potentially recommend to the user
        // Discover the user's neighbors (nbrs) in the graph by:
            // Look at articles the user has authored or supported
            // Look at other users that have authored or support the same articles
        // Find articles the user's neighbors have authored or supported that the users hasn't read
        // From these "candidate" articles, calculate a simple ranking score
            // candidateScore is weighted near support plus weighted global support
        // Sort these candidate articles by candidateScore, then by postTime
        // Check how many more articles we need to discover
            // If a user is new or has a small set of neighbors, we might not find 60 relevant articles
        // For any remaining article need, fill it with the most recent articles published globally
        const result = await session.run(
            `
            MATCH (u:User {id: $accountId})

            OPTIONAL MATCH (u)-[:AUTHOR|SUPPORT]->(:Article {inCirculation:true})<-[:AUTHOR|SUPPORT]-(nbr:User)
            WHERE nbr.id <> $accountId
            WITH u, collect(DISTINCT nbr) as nbrs

            OPTIONAL MATCH (a:Article {inCirculation:true})<-[:AUTHOR|SUPPORT]-(n:User)
            WHERE n IN nbrs AND NOT (u)-[]->(a)
            WITH u, a, count(DISTINCT n) AS nearSupport
            OPTIONAL MATCH (a)<-[:SUPPORT]-(:User)
            WITH u, a, nearSupport, count(*) AS globalSupport
            WITH u, a, 
                (nearSupport * $NEAR_SUPPORT_WEIGHT) + (globalSupport * $GLOBAL_SUPPORT_WEIGHT) AS candidateScore
            ORDER BY candidateScore DESC, a.postTime DESC
            WITH u,
                collect(CASE WHEN a IS NULL THEN NULL ELSE {id:a.id, score:candidateScore} END) AS tmp
            WITH u, [x IN tmp WHERE x IS NOT NULL] AS localCands

            WITH u, localCands, toInteger($candidateLimit) AS lim
            WITH u, localCands, lim, (lim - size(localCands)) AS need
            WITH u, localCands, lim, CASE WHEN need > 0 THEN need ELSE 0 END AS need

            CALL {
                WITH u, lim, need
                WITH u, lim, need WHERE need > 0
                MATCH (g:Article {inCirculation:true})
                WHERE NOT (u)-[]->(g)
                WITH u, lim, need, g
                ORDER BY g.postTime DESC
                LIMIT $candidateLimit
                RETURN collect({id:g.id, score:0.0}) AS globalRecent
            }

            WITH localCands, lim, coalesce(globalRecent, []) AS globalRecent
            WITH CASE
                    WHEN size(localCands) >= lim THEN localCands[0..lim]
                    ELSE localCands + globalRecent[0..(lim - size(localCands))]
                END AS combined
            
            UNWIND combined AS c
            RETURN DISTINCT c.id AS articleId
            `,
            {
                accountId: lowerAddress,
                candidateLimit: neo4j.int(candidateLimit),
                NEAR_SUPPORT_WEIGHT,
                GLOBAL_SUPPORT_WEIGHT,
            }
        );

        // Return the results of the article search to be used in Article Ranking
        return result.records.map(r => r.get('articleId'));
    } catch (err) {
        console.error('Error fetching improved article recommendations:', err);
        throw err;
    } finally {
        await session.close();
    }
};
Previous
Subgraph