Flaksator – analiza przechowywanych danych: rzeczowniki

Stara implementacja Flaksatora bazowała na dość skomplikowanym, tekstowym i pół-skompresowanym formacie. Plus był taki – że względnie dało się to edytować ręcznie… Dopóki się pamiętało o co w formacie chodziło. Aktualnie planuję umieścić słowniki w jakiejś bazie noSQL. Nie potrzebuję żadnego rozbuchanego systemu typu Cassandra czy MongoDb – wystarczy mi ten cusotm made: LiteDB. Zweryfikowałem to już przy innej okazji i dla potrzeb Flaksatora jest więcej niż wystarczające.
Tym niemniej, potrzebuję skonwertować istniejące bazy tekstowe do nowego formatu. Prawdopodobnie najszybciej będzie po prostu odczytać i zdeserializować obiekty ze starego systemu, skonwertować je do obiektów z nowego systemu i zapisać gotowce do noSQL. Ale tak czy siak, dla porządku spróbuję przypomnieć sobie, jak działał ten stary format:

Rzeczowniki

alibi|!N|$7,4,18,11|#
pies|@ps|!LM4|$3,8|+1D1|%V1psie|%L1psie|%C1psa
jelit|!NN3|%L1jelicie
bliźni|!PM1|$7,1|%G1bliźniego|%D1bliźniemu|%C1bliźniego|%A1bliźnim|%V1bliźni|%L1bliźnim|%N2bliźni|%G2bliźnich|%D2bliźnim|%V2bliźnich|%L2bliźnich|%A2bliźnimi|%C2bliźnich|%G1bliźniego|%D1bliźniemu|%C1bliźniego|%A1bliźnim|%V1bliźni|%L1bliźnim|%N2bliźni|%G2bliźnich|%D2bliźnim|%V2bliźnich|%L2bliźnich|%A2bliźnimi|%C2bliźnich
boa|!L|$3,11|#
kakao|!N|#

Jak widać format jest dość paskudny, ale nawet niewprawne oko jest w stanie wypatrzeć kilka faktów:

  1. Jeden wiersz, to jedno słowo. Porządek jest przypadkowy – w kolejności dodawania.
  2. “Pajpy” rozdzielają definicję na różne sekcje
  3. Kolejność sekcji wydaje się dość swobodna, poza pierwszą.
  4. Pierwsza sekcja zawiera “root” – sam temat słowa, ale nie samo słowo w mianowniku (chociaż w niektórych przypadkach tak jest) – jak można by oczekiwać
  5. Słowa z regularną odmianą mają bardzo krótkie definicje.
  6. Za to te nieregularne – masakryczną

Na pewno zagadką jest znaczenie symboli takich jak !, #, $ czy @. Zerknijmy na kod – nie da się tego dłużej odwlekać…

// ...
#if DEBUG
    if (line.Trim().StartsWith("EOF"))
        break; // for testing purposes
#else
    if (line.Trim().StartsWith("EOF"))
        continue; // ignore
#endif
    if (line.Trim().StartsWith(";"))
        continue; // entry is commented out

Noun noun = _grammarSerializers.NounSerializer.Load(line);

Ok, widać ciekawe podejście do zawężenia wczytywania zawartości pliku tylko do wybranej sekcji lub wyrazów. Zapewne na potrzeby testów nowych danych. Ten schemat powtarza się także przy innych częściach systemu, więc dalej będę go pomijał. Zerknijmy do wnętrza fabryki:

internal class OldNounSerializer : IGrammaticalWordSerializer<Noun>
{
    public Noun Load(string line)
    {
        if (string.IsNullOrEmpty(line)) return null;

        Noun noun = new Noun();

        string[] elements = line.Split('|');
        noun.Root = elements[0]; // always will be at last one element in nonempty string

        if (elements.Length > 1)
        {
            // ...
        }

        // check all data is specified
        if (noun.Genre == GrammaticalGender._Unknown)
            return null;

        return noun;
    }

Łatwo się z tego domyśleć, że w wykropkowanej części kodu musi być przynajmniej odczytana informacja o rodzaju danego rzeczownika. Czyli wymagane są przynajmniej dwie sekcje – ta pierwsza z tematem i ta z informacją o rodzaju.

for (int i = 1; i < elements.Length; i++)
{
    string str = elements[i];
    if (string.IsNullOrEmpty(str))
        continue;

    switch (str[0])
    {
         // ...
    }
}

Czyli faktycznie – sekcja z tematem musi być pierwsza, reszta jest dowolna. I to pierwszy znak informuje o jej rodzaju. Niestety brakuje tu zabezpieczenia weryfikującego “liczność” sekcji – część ma sens tylko przy jednym wystąpieniu, część nie.

case '#':
{
    noun.IsConstant = true;
    break;
}

Czyli “#” oznacza słowa nieodmienne. Definicja kakao wydaje się dzięki temu oczywista. Ale co tam się dzieje z boa? Powinna być tylko sekcja “!” a tymczasem jest jeszcze sekcja “$” – ciekawe.

case '!':
{
    if (str.Length > 1)
    {
        noun.Genre = EnumHelper.GetWordGenre(str[1]);
        // TODO: read more details
        if (str.Length > 3)
        {
            noun.DeclinationType = str.Substring(2, 2);
        }
    }
    else
    {
        // err
        return null;
    }

    break;
}

Uuu, prymitywne. Sekcja “!” faktycznie definiuje informację o rodzaju, który definiuje pierwsza litera definicji:

public static GrammaticalGender GetWordGenre(char code)
{
    switch (code)
    {
        case 'L':
            return GrammaticalGender.MasculineLife;
        case 'P':
            return GrammaticalGender.MasculinePerson;
        case 'T':
            return GrammaticalGender.MasculineThing;
        case 'F':
            return GrammaticalGender.Feminine;
        case 'N':
            return GrammaticalGender.Neuter;
    }

    return GrammaticalGender._Unknown;
}

Ale z kodu wynika, że poza literą może tam być coś jeszcze. I to widać np. w definicji psa: “!LM4”. Dla tych co nie pamiętają z lekcji polskiego (lub łaciny ;-)), każdy rodzaj posiada swoje tablice deklinacyjne. Często po kilka. I tu trzeba je wskazać. Niestety zachowywane jest to jako łańcuch znaków, i analizowane gdzie indziej (och, jaki błąd!), więc zjamę się tym ronież przy innej okazji 😉

Dalej:

case '*':
{
    if (str.Length > 1)
    {
        noun.IrregularGenre = EnumHelper.GetWordGenre(str[1]);
        // TODO: read more details
        noun.HasIrregularGenre = noun.IrregularGenre != GrammaticalGender._Unknown;
    }
    else
    {
        // err
        return null;
    }

    break;
}

O, a to jest ciekawe. W jakiś sposób wyraz mutuje! Z tego co pamiętam, ma to związek z faktem, że znaczeniowo wyraz ma inną rolę niż odmianę. np.

białas|!PM4|$7,1,6|+1L1|+1V1|+2N1|+2V1|*L

Wynika z tego, że wyraz odmienia się jako męskożywotny ale funkcjonalnie jest męskoosobowy (dotyczy w końcu człowieka(r. męski) o bladej skórze). Chyba. Trochę będę to musiał uporządkować – dla kodu losującego bardziej istotna była funkcja wyrazu w zdaniu bardziej niż jego odmiana, stąd taka dziwna podmiana (a może odwrotnie…) Zdecydowanie do przerobienia. Zresztą, wystarczy trochę poczytać O deklinacjach by się podłamać złożonością tematu. Być może w ogóle przebuduję sposób realizacji tego…

case '@':
{
    if (str.Length > 1)
    {
        // take root for other cases then Nominative
        noun.RootOther = str.Substring(1);
    }
    else
    {
        // err
        return null;
    }

    break;
}

Ok, czyli “@” oznacza sytuacje, gdy temat przybiera formy oboczne, ciężkie do wyprowadzenia z tablic deklinacji. Tu nie jest to jakoś analizowane ani weryfikowane – dopiero w samym silniku deklinatora. Wynika też z tego, że główny segment oznacza temat tylko dla mianownika. Trzeba to w takim razie uelastycznić…

case '+':
{
    if (str.Length > 2)
    {
        // Specifies other than first index is used
        DecliantionNumber amount = EnumHelper.GetWordAmount(str[1]);
        InflectionCase aCase = EnumHelper.GetWordCase(str[2]);
        int postFixIndex = int.Parse(str.Substring(3));

        switch (amount)
        {
            case DecliantionNumber.Singular:
            {
                if (!noun.SingularPostfixSelector.ContainsKey(aCase))
                    noun.SingularPostfixSelector.Add(aCase, postFixIndex);
                else
                    noun.SingularPostfixSelector[aCase] = postFixIndex;
                break;
            }
            case DecliantionNumber.Plural:
            {
                if (!noun.PluralPostfixSelector.ContainsKey(aCase))
                    noun.PluralPostfixSelector.Add(aCase, postFixIndex);
                else
                    noun.PluralPostfixSelector[aCase] = postFixIndex;
                break;
            }
        }
    }
    else
    {
        // err
        return null;
    }

    break;
}

Ok, czyli “+” wybiera inne niż pierwsze końcówki deklinacyjne przy regularnej odmianie. Lista standardowych końcówek znajduje się w pliku NounPostfixes.txt. Jeśli w danej deklinacji, w danym rodzaju dla danego przypadku dostępne jest więcej niż jedna końcówka to są rozdzielone spacjami. Np. TG2M5|an anów.

case '%':
{
    if (str.Length > 3)
    {
        noun.IsException = true;
        InflectionCase aCase = EnumHelper.GetWordCase(str[1]);
        DecliantionNumber amount = EnumHelper.GetWordAmount(str[2]);
        string txt = str.Substring(3);

        WordToken token = new
            WordToken(txt, aCase, amount);
        noun.Irregulars.Add(token);
    }
    else
    {
        // err
        return null;
    }

    break;
}

Sekcja definiuje oddzielnie każdą nieregularną odmianę – wskazując przy tym o który przypadek i liczbę chodzi. Dzięki temu regularne części zawsze działają.

case '$':
{
    string cats = str.Substring(1);
    // noun.Categories.Clear();
    if (!string.IsNullOrEmpty(cats))
    {
        string[] arr = cats.Split(',');
        foreach (string catId in arr)
        {
            int id = int.Parse(catId);

            if (!noun.Categories.Contains(id))
                noun.Categories.Add(id);
        }

    }

    break;
}

Ta sekcja definiuje przypisanie wyrazu do kategorii. Niewiele ich to ma, i nie było to do tej pory używane. I jest to najważniejsza funkcja, którą chcę zrealizować w wersji 2.* Flaksatora. I się nam wąż boa rozjaśnił – należy do kategorii 3 i 11 (Animal i Weapon – hehehe).

I ostatnia sekcja, która w przykładach do tej pory nie wystąpiła, a jest bardzo ciekawa:

case '^':
{
    if (str.Length == 2)
    {
        // only singular/plural word - no sense meaning or grammar
        DecliantionNumber amount = EnumHelper.GetWordAmount(str[1]);
        if (amount == DecliantionNumber.Plural)
            noun.CanBePlural = false;
        else if (amount == DecliantionNumber.Singular)
            noun.CanBeSingular = false;
    }
    else
    {
        // err
        return null;
    }

    break;
}

Otóż, oznacza ona wyrazy, które mają wyłącznie liczbę pojedynczą (np. zło, robactwo) lub mnogą (np. plecy). Niekoniecznie gramatycznie, ale głównie znaczeniowo.
Uch. Na dzisiaj chyba tyle. Przymiotniki są prostsze i pójdą na drugi ogień.

Na koniec, oczywiście nowa piosenka:

WYPASIONE PŁYTY

Zapach Twych serc, służba bram,
Te oto dręczą mnie krajanki.
W podupadłej goleni swojego złamania, błagasz o głos,
Tak też już skończysz, jak nóż, białasie przewracam Cię na czaszkę.
Bez przebaczenia, padasz już katastrofalny,
Przegrałeś, zginąłeś, to nie są żarty.

(Refren)
Przecinając Twą tętnice czuję na swej twarzy krew,
Leci ona strumieniami, goreje niczym koszmarny lew.
W wybałuszonym Murzynie dziś morduję, sanitariuszów, dziadów i inne trumny.
Padasz na ziemię, już pogwałcony, szatan pożarł Twoją duszę,
Resztę pożrą robaki, dostaną ucztę, całą Twą tuszę.

(Interludium)

(Refren)
Przecinając Twą tętnice czuję na swej twarzy krew,
Leci ona strumieniami, goreje niczym koszmarny lew.
W wybałuszonym Murzynie dziś morduję, sanitariuszów, dziadów i inne trumny.
Padasz na ziemię, już pogwałcony, szatan pożarł Twoją duszę,
Resztę pożrą robaki, dostaną ucztę, całą Twą tuszę.

Rozochocony specyfik i kostnica zamulona,
Nastanie ojczyzna zmemłana.
Nastąpił tok zastanawiania,
Nadszedł już klej zabijania.
Rzucam się na Ciebie z płytą,
Atakuję tu przed siekierą.

(Refren)
Przecinając Twą tętnice czuję na swej twarzy krew,
Leci ona strumieniami, goreje niczym koszmarny lew.
W wybałuszonym Murzynie dziś morduję, sanitariuszów, dziadów i inne trumny.
Padasz na ziemię, już pogwałcony, szatan pożarł Twoją duszę,
Resztę pożrą robaki, dostaną ucztę, całą Twą tuszę.

Pojawiają się tam niektóre wspomniane w poście wyrazy, jak i kilka błędów, związanych z niedopasowaniem kategorii i odmian. Uch… Ciekawe ile osób je wyłapie.